fix(chat): keep plan responses visible in the transcript - #342
Conversation
`isPlanResponse` was effectively a "hide this message" flag, while the only renderer for a plan was the floating `PlanViewer` — which closes once the task moves past plan review. The reviewed plan therefore disappeared from the conversation as soon as the user replied. Worse, everything the agent produced after the plan (follow-up text, tool calls, even pending approvals) was appended to the same hidden message and vanished with it. One real record carried a 6,765-char plan alongside 24 unrendered parts. - provider-event-replay: finalize the plan message and start a fresh assistant message when a renderable event arrives after a plan. `plan_ready` still updates the plan in place. - ChatPanel: drop the `isPlanResponse` filter and render plans through a new `ConversationPlanCard` so they persist in the transcript. The floating `PlanViewer` keeps its approve/revise controls. - chat-panel.utils: `resolvePlanMessagePresentation` shows the card alone for fresh plan messages, and card + existing parts for legacy merged records so already-stored conversations become readable again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 92d01c80ff
ℹ️ 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".
| target = createStreamingAssistantMessage({ | ||
| taskId: args.taskId, | ||
| count: current.length + messageIndexOffset, | ||
| provider: args.provider, | ||
| model: args.model, | ||
| }); |
There was a problem hiding this comment.
Preserve native turn metadata when splitting after a plan
When provider_turn or history_boundary is emitted before a plan and its follow-up content—as the provider adapters do for assistant content—the metadata is attached to the plan message, but this newly created message starts without nativeProviderSessionId, nativeProviderTurnId, or providerBoundary. Consequently, the visible post-plan response is treated as predating native turn tracking, disabling its fork/rollback actions, while the plan row incorrectly retains the boundary for the entire provider turn. Transfer the turn metadata to the new message, or otherwise ensure the split rows continue to represent the same native turn correctly.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in 3e262b6.
Reproduced the exact sequence through replayProviderEventsToTaskState: provider_turn(turn-a) → plan_ready → provider_turn(turn-b) → text produced a plan row carrying turn-b and a follow-up row with no native turn at all, so buildConversationTurnActionStateByMessageId returned fork.enabled: false with "This response predates native turn tracking" on the only visible row of the turn.
Rather than copying the (already clobbered) metadata forward, the split now happens when the metadata arrives: a plan row is sealed once presented, so a provider_turn or history_boundary announcing a different native turn opens the follow-up message and lands there, leaving the plan row on its own turn. Claude emits history_boundary ahead of provider_turn and Codex emits it after, so both events can start the split. Rows that continue the plan's own turn (no new provider_turn) inherit its identity instead, which also closes the pre-existing case where a plan split off from streamed commentary started with no turn metadata.
Both rows now report their own turn and both expose Fork. Four regression tests in tests/provider-event-replay.test.ts cover the new-turn split (both emitter orderings), the same-turn inheritance, and the commentary-split plan row.
Splitting the post-plan response into its own message left that row without `nativeProviderSessionId`/`nativeProviderTurnId`, so the only visible row of the turn reported "this response predates native turn tracking" and lost its fork/rollback actions. The sealed plan row meanwhile absorbed the follow-up turn's id and history boundary, because `provider_turn` and `history_boundary` arrive before the content they describe and landed on whatever assistant row was last. A plan row is sealed once presented, so metadata for a *different* native turn now opens the follow-up message instead of overwriting the plan's. Claude emits `history_boundary` ahead of `provider_turn` and Codex emits it after, so both events can start the split. Rows that continue the plan's own turn inherit its identity, which also covers the pre-existing gap where a plan split off from streamed commentary started with no turn metadata at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
A plan shown with "shall I proceed?" disappeared from the conversation as soon as the user replied, and any assistant content produced after the plan was swallowed with it.
Changes
Fixes
Refactors / Chores
resolvePlanMessagePresentationinchat-panel.utils.tsand a newConversationPlanCardcomponent.Test Evidence
bun test tests/provider-event-replay.test.ts tests/chat-panel-utils.test.ts— 86 pass, 0 fail. 7 new tests pin the regression (written test-first).bun test(full) — 3,168 pass. The 12 failures are byte-identical to the pre-change baseline (executable path discovery, env, 5s git timeout).bun run check:max-lines-ratchet/check:switch-exhaustiveness/check:doc-paths/check:reliability-gates— all pass.bun run typecheck— the 3 remaining errors are pre-existingOrbStatemismatches inturn-activity.utils.ts(athinking-orbsdependency type, untouched here) and reproduce atHEADwithout this change.planText6,765 chars /content9,300 chars / 24 parts). Before the fix the legacy record rendered only two user messages — the entire assistant turn was gone; after the fix the plan card renders and the 24 previously hidden parts render below it.Notes
PlanVieweris unchanged and keeps its approve/revise controls; this PR only adds the persistent transcript copy.plan_readyre-presentation still updates the plan message in place rather than starting a new one.tool_use/system_event) are collapsed by default and only the final text is expanded. That is the existingshowInterimMessagesbehaviour for assistant traces, not something introduced here.border,card,primary), so no theme file changes were needed.🤖 Generated with Claude Code