From 89073d4c5c1b8330634e034fa2fdb80334745e85 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:45:11 +0200 Subject: [PATCH] Keep a Claude rewind anchor off subagent messages (#5289) Task subagent frames stream on the same query as the conversation, and rememberTranscriptProgress recorded any assistant frame as the current turn's rewind anchor. When a turn's last assistant frame came from a subagent, the anchor held a uuid that lives on the subagent's sidechain rather than on the session's own chain, so forkSession rejected it with "Message not found in session" and every later rewind in that conversation failed. Skip frames that carry a parent_tool_use_id when recording anchors. The persisted history path already skips sidechain entries; the live stream now applies the same rule. Closes #5284 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../claude/agent.rewind-anchors.test.ts | 40 ++++++++++++++++++- .../server/agent/providers/claude/agent.ts | 7 ++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts b/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts index 15a2b8eba9..1b1932e9cd 100644 --- a/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.rewind-anchors.test.ts @@ -9,7 +9,7 @@ import type { AgentSession } from "../../agent-sdk-types.js"; const SESSION_ID = "session-1"; /** Claude Code replies to a turn either with an assistant message or with nothing at all. */ -type TurnOutcome = { assistantMessageId: string } | "no-response"; +type TurnOutcome = { assistantMessageId: string; subagentMessageId?: string } | "no-response"; function initMessage(): Record { return { @@ -40,6 +40,21 @@ function assistantReply(uuid: string): Record { }; } +/** + * A message from a Task subagent. The CLI streams these on the same query as the main + * conversation, and their uuids live on the subagent's sidechain, not on the session's + * own message chain, so `forkSession` cannot resolve one. + */ +function subagentReply(uuid: string): Record { + return { + type: "assistant", + uuid, + session_id: SESSION_ID, + parent_tool_use_id: "toolu_subagent_1", + message: { id: uuid, role: "assistant", content: [{ type: "text", text: "subagent work" }] }, + }; +} + function successResult(): Record { return { type: "result", @@ -110,6 +125,9 @@ function createConversation(outcomes: TurnOutcome[]): Conversation { continue; } enqueue(assistantReply(outcome.assistantMessageId)); + if (outcome.subagentMessageId) { + enqueue(subagentReply(outcome.subagentMessageId)); + } enqueue(successResult()); } closedRef.value = true; @@ -229,3 +247,23 @@ describe("Claude rewind across a turn that produced no response", () => { expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]); }); }); + +describe("Claude rewind after a turn whose last assistant message came from a subagent", () => { + test("forks at the turn's own reply, not at the subagent message", async () => { + const conversation = createConversation([ + { assistantMessageId: "assistant-1", subagentMessageId: "subagent-1" }, + { assistantMessageId: "assistant-2" }, + ]); + const rewindSdk = new FakeClaudeSdk(); + const session = await createSession(conversation, rewindSdk); + + try { + await runTurns(session, 2); + await session.revertConversation?.({ messageId: conversation.userMessageIds[1] }); + } finally { + await session.close(); + } + + expect(rewindSdk.recordedForks).toEqual([{ upToMessageId: "assistant-1" }]); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 30fdebd974..b4b516fe1e 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -3054,6 +3054,13 @@ class ClaudeAgentSession implements AgentSession { if (!messageId) { return; } + // Subagent frames ride the same stream as the conversation, but their uuids live on the + // subagent's sidechain, so forkSession cannot resolve one. Anchoring a turn on one leaves + // rewind throwing "Message not found in session". The persisted history path already + // skips sidechain entries; the live stream has to skip them too. + if (readClaudeParentToolUseId(message)) { + return; + } if ( message.type === "user" && !isSyntheticUserEntry(message) &&