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 <uuid> 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>
This commit is contained in:
paseo-bot[bot]
2026-09-23 19:45:11 +02:00
committed by GitHub
parent ec43e90670
commit 89073d4c5c
2 changed files with 46 additions and 1 deletions
@@ -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<string, unknown> {
return {
@@ -40,6 +40,21 @@ function assistantReply(uuid: string): Record<string, unknown> {
};
}
/**
* 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<string, unknown> {
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<string, unknown> {
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" }]);
});
});
@@ -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 <uuid> 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) &&