Skip to content

fix(runtime): persist one assistant message per AI SDK step, replay signed thinking in tool turns - #633

Merged
Astro-Han merged 12 commits into
mainfrom
fix/step-level-event-log
Jul 8, 2026
Merged

fix(runtime): persist one assistant message per AI SDK step, replay signed thinking in tool turns#633
Astro-Han merged 12 commits into
mainfrom
fix/step-level-event-log

Conversation

@Astro-Han

Copy link
Copy Markdown
Contributor

Summary

The runtime accumulated an entire turn's assistant text/thinking into one AssistantMessage flushed at turn end, losing the text↔tool timeline and overwriting per-step Anthropic thinking signatures. This PR flushes one AssistantMessage per AI SDK step (its own text + thinking + signature), rotates the assistant messageId at each step boundary, stamps stepId onto tool_start events, and rewrites replay materialization to merge each step's signed reasoning + text + tool calls into one provider assistant message — the shape Anthropic interleaved thinking requires.

Why

The single-message-per-turn ledger had three concrete defects: (1) text → tool → text order was unrecoverable from storage; (2) multi-step turns kept only the last step's thinking signature, mismatched against the whole turn's concatenated thinking; (3) because of (2), model-history.ts skipped signed-thinking replay entirely for any tool-calling turn (signed_thinking_in_tool_turn_skipped), so the model lost its reasoning chain on every tool turn. Per-step persistence fixes all three at the source and unblocks the upcoming streaming-UI rework (step timeline = trow grouping).

Scope

Changed: runtime (per-step flush, step-boundary handling incl. finish-step/step-finish fallback, replay regrouping by stepId, read-model pairing by message id, error-path partial flush, unmatched_tool_result no longer replay-blocking), core (ToolStartEvent.stepId, RuntimeEventRefs.stepId), ui/desktop neutrality (turn text/thinking concatenation, streaming slot reset on messageId change, Markdown export walks all steps).

Not included: UI streaming rework (thinking block, tool trow, fade-in — next PR); ai-sdk-backend.ts module split (tracked separately); per-step token_usage (stays turn-level).

Verification

@maka/runtime 977 pass / @maka/desktop 2211 pass / @maka/storage + @maka/headless green / npm run typecheck clean. New tests: multi-step flush + tool_start.stepId, per-step vs legacy replay, thinking-only no-text step (locks the empty_text_skipped path that previously poisoned whole-ledger replay), orphan tool_result provider-native drop, provider-error partial flush, two-assistant read-model pairing, multi-step turn concatenation.

User-facing impact

Sessions recorded by this build replay signed thinking through tool turns (previously dropped). Partial output now survives provider errors, not just user aborts. Legacy sessions degrade to the old safe behavior. No schema migration: one turn simply may contain multiple assistant rows, which old readers already tolerate.

Reviewer notes

Reviewed pre-PR by Codex (xhigh) and DeepSeek v4 Pro; all accepted findings are in the last four commits. Highest-risk area: materializeRuntimeReplayPlan regrouping (Anthropic 400 on mistakes) — the pairing is order-independent via refs.stepId because tool calls persist before their step's assistant row (execute() fires mid-step; buffering appends would deadlock the permission flow). Rollback: single squash revert restores per-turn behavior; new-format ledgers then degrade to the legacy skip, not corruption.

Checklist

  • Scope matches the PR title and excludes unrelated changes
  • Verification lists commands/results
  • User-facing impact noted; no docs/changelog/migration needed
  • Risk, rollback, and review focus called out
  • UI changes include screenshots/video — n/a (no visual change; UI commits are behavior-neutral adaptation)

Astro-Han added 12 commits July 8, 2026 15:42
Emit a per-step AssistantMessage (text + thinking + signature) at each
finish-step boundary instead of one accumulated message at turn end, so the
ledger records the text-tool timeline at step granularity and each step's
Anthropic thinking signature stays paired with its own reasoning text.

Each step rotates a fresh assistant messageId; tool calls stamp their step id
onto tool_start (new ToolStartEvent.stepId -> RuntimeEventRefs.stepId) so model
replay can regroup a step's reasoning with its tool calls. Pure-tool steps write
no placeholder; the step-cap grace notice and abort partials flush per step.

Also count steps on the v6 finish-step chunk (the old step-finish check was
dead) and document start-step/finish-step as no-ops in the adapter.
Narrow the tool-turn signed-thinking replay skip to legacy per-turn history
(tool calls with no step id); per-step turns whose tool calls carry refs.stepId
now regroup into one provider assistant message (reasoning leads, then text,
then tool calls, then tool results) — the Anthropic-valid shape, so signed
thinking from tool-calling turns replays instead of being dropped.

Read-model projectThinking pairs each step's reasoning to its own assistant row
by step message id instead of attaching to the last same-turn assistant row.

Tests: reverse the tool-turn thinking case to the per-step (merged) shape, keep
a legacy (unpaired) suppression regression, add a multi-step backend integration
test and a two-assistant read-model pairing test.
materialize concatenates a turn's per-step AssistantMessages (text and thinking)
in step order, keeping the first step id as the anchor and advancing durationMs
to the turn's final assistant message. The desktop Markdown export joins all step
texts. The renderer text_delta slot resets on a messageId change so a new step's
answer starts a fresh bubble instead of appending onto the draining prior step.
Assert handleStreamChunk emits no events and leaves the text/thinking callbacks
untouched for start-step / finish-step chunks — the backend owns step accounting.
…ive replay

The empty text_complete a thinking/tool-only step emits (so the read-model gets
an assistant row for the step's reasoning) was classified unsupported_content in
the replay plan — a blocking diagnostic that degraded the WHOLE ledger to
stored-message projection. With interleaved thinking this is the most common
step shape, so any such step poisoned replay for the rest of the session.

Skip empty model text benignly (new empty_text_skipped diagnostic, non-blocking)
and pair a step's parked reasoning with its tool calls by stepId at flush time
in the materializer, so the no-text step still replays as one assistant message
[reasoning, tool-call…] with no empty text block. Locked by a new replay test
for the thinking + tool, no-text per-step shape.
…lay materializer

An orphan tool_result (call sliced away or ledger corrupt) is dropped on
purpose: a standalone tool message with no preceding tool_use is an Anthropic
400. The plan flags these as unmatched_tool_result; do not restore the old
item-by-item orphan emission.
…rror

The catch block only flushed the current step's streamed text/thinking on the
abort (user_stop) exit; a provider failure or watchdog timeout dropped it. Hoist
the flush ahead of both branches so partialOutputRetained reflects what the user
actually saw on every terminal exit. Regression: mock stream emits a text delta
then errors; the step's AssistantMessage persists with the streamed partial and
the turn still closes as an error.
…g diagnostic

The materializer already drops an orphan tool result on its own (a standalone
tool message with no preceding tool_use is an Anthropic 400), but listing
unmatched_tool_result in hasBlockingReplayDiagnostics meant one orphan degraded
the ENTIRE ledger to stored-message projection — the drop path was unreachable.
Unblock it: paired calls/results and signed reasoning replay provider-native,
the orphan is dropped. Rewrote the test that locked the old fallback and added
an end-to-end orphan + paired-history case.
…dary

If a dependency version emits the older step-finish name, matching only
finish-step would silently degrade back to one AssistantMessage per turn.
Duplicate boundaries are safe: the second flush no-ops on cleared accumulators
and an extra id rotation only discards an unused id.
…arkdown cast

model-history: document why paired/unpaired tool-turn classification is
turn-granular (one turn = one backend build; formats cannot mix within a turn)
and that pairedToolTurnIds exists for sliced-view safe degradation, not dead
state. conversation-markdown: replace the bare text cast with discriminated
union narrowing via flatMap; no behavior change.
On the catch-all path (stream closed without a trailing finish-step) the
current step id is already taken: the catch-all flush just used it for a
thinking-only last step's AssistantMessage (reuse duplicated a ledger id), and
a pure-tool last step's tool_starts carry it as stepId (replay would adopt the
grace text as that step's closer). Always newId() — a rotated-but-unused id is
discardable by design. Regression drives the backend through a patched
startStream (streamText always synthesizes trailing boundaries): real tool
execute in step 1, thinking-only step 2, abrupt close; asserts the grace id
collides with no assistant row and no tool_start.stepId, and the ledger has no
duplicate ids. Verified red against the old code.
@Astro-Han
Astro-Han merged commit b542bc9 into main Jul 8, 2026
3 checks passed
@Astro-Han
Astro-Han deleted the fix/step-level-event-log branch July 8, 2026 09:31
sunheyi6 added a commit to sunheyi6/maka-agent that referenced this pull request Jul 8, 2026
…rializer

PR apache#629 regressed the per-step persistence and RuntimeEvent replay
materializer introduced by apache#633. Restore flushStep/currentStepMessageId
(one AssistantMessage per AI SDK step, signed thinking merged with its
tool call, orphan tool_results dropped) while keeping the guidance
continuation pass and standing-steer prepareStep.
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.

1 participant