Skip to content

fix(agent): give each reasoning span its own AG-UI messageId - #3471

Merged
kwakayama merged 3 commits into
mainfrom
worktree-fix-reasoning-message-id-collision
Aug 8, 2026
Merged

kwakayama merged 3 commits into
mainfrom
worktree-fix-reasoning-message-id-collision

Conversation

@kojiwakayama

@kojiwakayama kojiwakayama commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

Found while reconstructing a trace from a production run with five reasoning blocks across five steps. All five blocks carried the same messageId:

<messageId>:reasoning:reasoning-0   ← ×5

Root cause

Both AG-UI browser encoders composed the run-global reasoning messageId from the provider's part id:

`${state.messageId}:reasoning:${event.id}`   // event.id === "reasoning-0"

Part ids are only unique within a step — providers restart them at reasoning-0 on every step. src/agent/react/use-chat/streaming/handler.ts:739 already documents this and compensates client-side; the encoders did not. Any consumer keying by messageId merges or overwrites four of the five spans, and a trace UI cannot tell the thinking blocks apart.

The doubled segment in :reasoning:reasoning-0 is the tell — a part id sitting where the code reads as an index.

Fix

A span is identified by its position in the run. Both encoders count reasoning spans and only advance on a genuine span open, so deltas and ends stay on the id their own span opened instead of recomposing from whatever part id they carry.

step-1 → <messageId>:reasoning:0
step-2 → <messageId>:reasoning:1
step-3 → <messageId>:reasoning:2
step-4 → <messageId>:reasoning:3
step-5 → <messageId>:reasoning:4

Two call sites, not one — lifecycle-browser-adapter.ts has the identical defect on the flag-gated v2 stream-lifecycle path, so the bug would return when VF_STREAM_LIFECYCLE_MODE flips.

Why ordinals (wire-format note)

This changes the emitted id shape. Deliberate:

  • veryfront-api already uses ordinals for these same spans — stream-snapshot-helpers.ts:217 and terminal-public-replay.ts:217 both compose :reasoning:${index}. Keeping the part id would leave a run emitting one id live and a different id on replay — the same class of defect this PR fixes.
  • Nothing parses these ids. No split(":reasoning:") or equivalent in either repo; they are opaque correlation keys.
  • Historical events keep their colliding ids either way. No format choice repairs a value that is already ambiguous, so preserving the old shape would preserve a format, not correctness.

An earlier revision used a :2 occurrence suffix on the part id to keep the first span byte-identical. Dropped: …:reasoning:reasoning-0:5 embeds two competing ordinals and is not a legible id.

Not addressed

veryfront-api indexes into the whole parts array while these encoders count reasoning spans, so the two paths now share a shape but not necessarily a value. Aligning them needs a follow-up.

Tests

Red-first, three new tests:

Test Red Green
browser-encoder: distinct id per span across 3 steps 1 unique id, needed 3 ✅
browser-encoder: delta/end stay on their span's id spans shared an id ✅
lifecycle-browser-adapter: distinct id per span across 2 steps ["…:reasoning-0","…:reasoning-0"] ✅

Existing assertions updated to the ordinal scheme in browser-encoder.test.ts and internal-agents/ag-ui-sse.test.ts — each was a first span in a fresh encoder state, so each becomes :reasoning:0.

Full suite green via the pre-push gate: 3,776 passed, 0 failed. deno check / fmt / lint clean.

Summary by CodeRabbit

  • Bug Fixes

    • Improved reasoning event tracking during multi-step runs.
    • Reasoning spans now receive unique, stable identifiers, even when providers reuse the same source ID.
    • Start, content, and end events consistently retain each span’s assigned identifier.
    • Unmatched reasoning-end events are now ignored.
  • Documentation

    • Updated API reference links to point to the correct source locations.

Providers restart reasoning part ids at `reasoning-0` on every step, and both
AG-UI browser encoders composed the run-global reasoning messageId straight
from that part id. Every reasoning span in a multi-step run therefore shared
one id. Production run 2f7ae3d4 emitted five REASONING_MESSAGE_START events
all keyed `msg-2ba40c86...:reasoning:reasoning-0`, so any consumer keying by
messageId merges or overwrites four of the five, and a trace UI cannot tell
the five thinking blocks apart.

The part id was never a run-global identifier. src/agent/react/use-chat/
streaming/handler.ts:739 already documents the reuse and compensates for it
client-side; the encoders did not.

A span is now identified by its position in the run. Both encoders count
reasoning spans and only advance on a genuine span open, so deltas and ends
stay on the id their own span opened rather than recomposing from whatever
part id they carry.

Ordinals rather than a disambiguating suffix on the part id, because
veryfront-api already rebuilds these same events with an ordinal
(stream-snapshot-helpers.ts:217 and terminal-public-replay.ts:217). Keeping
the part id would have left a run emitting one id live and a different id on
replay -- the same class of defect. Nothing parses these ids; they are opaque
correlation keys, so the shape is free to change. Historical events keep
their colliding ids either way, since no format choice repairs a value that
is already ambiguous.

lifecycle-browser-adapter carries the identical defect on the flag-gated v2
stream-lifecycle path and is fixed alongside, so the bug does not return when
VF_STREAM_LIFECYCLE_MODE flips.

Not addressed: veryfront-api indexes into the whole parts array while the
encoders count reasoning spans, so the two paths now share a shape but not
necessarily a value. Aligning them needs a follow-up.
@kojiwakayama
kojiwakayama requested a review from kwakayama as a code owner August 8, 2026 11:16
@coderabbitai

coderabbitai Bot commented Aug 8, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f1313ec3-7db8-492b-b885-f1e23fa2a737

📥 Commits

Reviewing files that changed from the base of the PR and between da9a0ed and 8ba8a8b.

📒 Files selected for processing (4)
  • src/agent/ag-ui/browser-encoder.test.ts
  • src/agent/ag-ui/browser-encoder.ts
  • src/agent/ag-ui/lifecycle-browser-adapter.test.ts
  • src/agent/ag-ui/lifecycle-browser-adapter.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/agent/ag-ui/lifecycle-browser-adapter.test.ts
  • src/agent/ag-ui/browser-encoder.test.ts
  • src/agent/ag-ui/browser-encoder.ts
  • src/agent/ag-ui/lifecycle-browser-adapter.ts

📝 Walkthrough

Walkthrough

The AG-UI browser encoder and lifecycle adapter now assign run-scoped ordinal IDs to reasoning spans. Tests cover repeated provider IDs, stable span identities, and unmatched end events. API reference links were updated for shifted source lines.

Changes

Reasoning span identity

Layer / File(s) Summary
Encoder identity generation
src/agent/ag-ui/browser-encoder.ts
The encoder tracks reasoning spans with a per-run ordinal counter. Start, continuation, and end events reuse the active span ID. Unmatched end events emit nothing.
Lifecycle adapter integration
src/agent/ag-ui/lifecycle-browser-adapter.ts
The adapter assigns run-scoped reasoning IDs and reuses them across each span’s start, content, and end events.
Identity regression coverage and references
src/agent/ag-ui/*.test.ts, src/internal-agents/ag-ui-sse.test.ts, docs/api-reference/veryfront/agent.md
Tests verify distinct IDs across spans, stable IDs within each span, and ignored unmatched ends. API reference links use updated source locations.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant RuntimeStream
  participant LifecycleBrowserAdapter
  participant BrowserEncoder
  participant AGUIClient
  RuntimeStream->>LifecycleBrowserAdapter: reasoning stream event
  LifecycleBrowserAdapter->>BrowserEncoder: request reasoning identity
  BrowserEncoder->>AGUIClient: emit event with stable ordinal ID
Loading

Possibly related PRs

Suggested reviewers: kwakayama

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the fix for assigning unique AG-UI message IDs to reasoning spans.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-fix-reasoning-message-id-collision

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fef5d99936

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agent/ag-ui/browser-encoder.test.ts Outdated
Comment on lines +725 to +728
// Providers restart part ids at `reasoning-0` in every step, so composing the
// AG-UI id from the part id alone collides across a multi-step run. Observed
// in production run 2f7ae3d4, where all five reasoning blocks shared
// `msg-2ba40c86...:reasoning:reasoning-0`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove production identifiers from the test comment

This comment records a production run identifier and part of its message identifier in a test. Replace both with placeholders or a synthetic example so repository history does not retain identifiers from production activity.

AGENTS.md reference: AGENTS.md:L96-L101

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in da9a0ed. Dropped the run id and the message-id prefix; the comment now states the collision shape generically (<messageId>:reasoning:reasoning-0), which is what the test actually pins. Also redacted the same identifiers from the PR description, since AGENTS.md:96 covers those too.

Comment thread src/agent/ag-ui/browser-encoder.ts Outdated
activeTextContentId: string | null;
textContentIndex: number;
reasoningMessageId: string | null;
reasoningSpanIndex: number;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the encoder state addition backward-compatible

AgUiBrowserEncoderState is re-exported from the public veryfront/agent surface, so making reasoningSpanIndex required breaks downstream callers that construct a previously valid state object and pass it to the public mapping or finalization functions. Make the new counter optional with a zero fallback, or otherwise avoid adding a required property to the public state contract.

AGENTS.md reference: AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in da9a0ed. reasoningSpanIndex is now optional and reads as 0 when absent, so a hand-built state that predates the counter stays valid through the public mapping and finalization functions:

const index = state.reasoningSpanIndex ?? 0;
state.reasoningSpanIndex = index + 1;

createAgUiBrowserEncoderState() still seeds it to 0, so the normal path is unchanged. The v2 path in lifecycle-browser-adapter.ts keeps its counter in closure state and never touched a public type, so it needed no change.

…ct ids

- reasoningSpanIndex is optional on the public AgUiBrowserEncoderState so a
  state object built before the counter existed stays valid; absent reads as 0.
- Drop the production run and message identifiers from the test comment.
- Regenerate docs/api-reference for the shifted source line numbers.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/agent/ag-ui/browser-encoder.ts (1)

740-743: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not open a reasoning span for an unmatched end event.

Both paths create a new reasoning ID when they receive an end event without an active span. They then emit ReasoningMessageEnd without a matching start. Return no event for an unmatched end. Add regression coverage for this case.

  • src/agent/ag-ui/browser-encoder.ts#L740-L743: return an empty event list when state.reasoningMessageId is null.
  • src/agent/ag-ui/lifecycle-browser-adapter.ts#L133-L137: make endReasoning() return only the active ID. Do not call continueReasoning().
  • src/agent/ag-ui/lifecycle-browser-adapter.ts#L201-L204: emit ReasoningMessageEnd only when endReasoning() returns an ID.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent/ag-ui/browser-encoder.ts` around lines 740 - 743, Prevent unmatched
reasoning-end events from producing end events: in
src/agent/ag-ui/browser-encoder.ts lines 740-743, return an empty event list
when state.reasoningMessageId is null; in
src/agent/ag-ui/lifecycle-browser-adapter.ts lines 133-137, update
endReasoning() to return only the active ID without calling continueReasoning();
and in lines 201-204, emit ReasoningMessageEnd only when endReasoning() returns
an ID. Add regression coverage for unmatched end events.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/agent/ag-ui/browser-encoder.ts`:
- Around line 740-743: Prevent unmatched reasoning-end events from producing end
events: in src/agent/ag-ui/browser-encoder.ts lines 740-743, return an empty
event list when state.reasoningMessageId is null; in
src/agent/ag-ui/lifecycle-browser-adapter.ts lines 133-137, update
endReasoning() to return only the active ID without calling continueReasoning();
and in lines 201-204, emit ReasoningMessageEnd only when endReasoning() returns
an ID. Add regression coverage for unmatched end events.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3665c267-e798-4742-bfbb-558e1777651b

📥 Commits

Reviewing files that changed from the base of the PR and between 4af1bb9 and da9a0ed.

📒 Files selected for processing (6)
  • docs/api-reference/veryfront/agent.md
  • src/agent/ag-ui/browser-encoder.test.ts
  • src/agent/ag-ui/browser-encoder.ts
  • src/agent/ag-ui/lifecycle-browser-adapter.test.ts
  • src/agent/ag-ui/lifecycle-browser-adapter.ts
  • src/internal-agents/ag-ui-sse.test.ts

An unmatched reasoning end fell through to opening a span, so it emitted a
ReasoningMessageEnd with no matching start and consumed a span ordinal,
shifting every later span's id. Both encoders now report nothing to close.

The browser encoder reuses closeOpenReasoningEvent, which already had this
guard for the tool-input-start path.
@kwakayama

Copy link
Copy Markdown
Contributor

@coderabbitai Confirmed and fixed in 8ba8a8bc2.

Verified the finding against the code first: reasoning-end called getReasoningMessageId(state, "continue"), which falls through to openReasoningMessageId when no span is open — so an unmatched end emitted a ReasoningMessageEnd with no matching start and consumed a span ordinal, shifting every later span's id. The dangling-end half is pre-existing; the ordinal burn is new with this PR's numbering, which puts it in scope here.

  • browser-encoder.ts: reasoning-end now returns closeOpenReasoningEvent(state), which already carried exactly this guard for the tool-input-start path. Net deletion, and the End payload shape is unchanged.
  • lifecycle-browser-adapter.ts: endReasoning() returns string | null from the active id only and no longer calls continueReasoning(); reasoning_end emits nothing when it returns null.

Regression coverage added to both suites (drops a reasoning end that closes no open span), each asserting the end is dropped and that the next real span is still ordinal 0. Confirmed red before the fix and green after; full src/agent/ag-ui/ suite is 29 passed / 157 steps.

One related case I did not change, to keep this diff scoped: a reasoning_content with no span open still opens one in the lifecycle adapter. browser-encoder handles that by synthesizing a ReasoningMessageStart first, so the two paths differ there. That asymmetry is pre-existing and unflagged — worth a follow-up rather than folding into this PR.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@kwakayama Confirmed. The fix prevents an unmatched reasoning end from emitting an end event and consuming a span ordinal. The regression tests cover both required conditions.

Would you like me to create a GitHub follow-up issue for the pre-existing reasoning_content lifecycle asymmetry?


✏️ Learnings added
Learnt from: kwakayama
URL: https://github.com/veryfront/veryfront-code/pull/3471

Timestamp: 2026-08-08T15:36:09.103Z
Learning: In the AG-UI browser encoders, a `reasoning_content` event with no open reasoning span has pre-existing asymmetric behavior. `src/agent/ag-ui/browser-encoder.ts` synthesizes a `ReasoningMessageStart` before content, while `src/agent/ag-ui/lifecycle-browser-adapter.ts` opens reasoning content without a corresponding start event. Treat this as a separate follow-up concern unless the change explicitly targets this behavior.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

@kwakayama

Copy link
Copy Markdown
Contributor

@coderabbitai Thanks for confirming. No follow-up issue for now — I'd rather not open a tracking issue that the repo owner hasn't asked for. The reasoning_content-with-no-open-span asymmetry is written up in the thread above if someone wants to pick it up.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@kwakayama Understood. I will not create a follow-up issue. The PR thread documents the reasoning_content asymmetry for future work.

🐇

You are interacting with an AI system.

@kwakayama
kwakayama added this pull request to the merge queue Aug 8, 2026
Merged via the queue into main with commit 7f6cb86 Aug 8, 2026
31 checks passed
@kwakayama
kwakayama deleted the worktree-fix-reasoning-message-id-collision branch August 8, 2026 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants