diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index f8145b62e..4e33e541c 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -16993,30 +16993,34 @@ export class AgentSession { /** * Look up the `ask` toolCall's persisted `arguments` and validate them * back into `questions`, for `/tree` `ask` re-answer (issue #5642). Walks - * up from the toolResult's parent past any interleaved sibling toolResults - * — `ask` runs `exclusive` (serialized execution) but a multi-tool-call - * assistant turn can still emit other tool calls first, so the persisted - * *parent* of the `ask` toolResult may be one of those toolResults rather - * than the assistant message itself (roboomp review on #5895) — until it - * finds the assistant entry that actually emitted `toolCallId`. Returns - * `undefined` when no ancestor entry holds a matching `ask` toolCall, or - * the arguments can't be resolved — the caller falls back to a plain leaf - * move rather than opening a picker with bad data. + * up from the toolResult's parent past any interleaved ancestor entries + * — sibling toolResults from other tool calls in the same turn (`ask` + * runs `exclusive`, which only serializes *execution*, not persistence + * order — roboomp review on #5895), and bookkeeping entries such as the + * `tool_execution_start` custom entry `#recordToolExecutionStart()` + * appends before every toolResult in real persisted sessions (chatgpt-codex + * review on #5895) — until it finds the assistant entry that actually + * emitted `toolCallId`. Stops at a `user` message (turn boundary) or a + * dead end. Returns `undefined` when no ancestor entry holds a matching + * `ask` toolCall, or the arguments can't be resolved — the caller falls + * back to a plain leaf move rather than opening a picker with bad data. */ #recoverAskReanswerQuestions(parentId: string | null, toolCallId: string): AskToolInput["questions"] | undefined { let current = parentId; while (current !== null) { const entry = this.sessionManager.getEntry(current); - if (entry?.type !== "message") return undefined; - if (entry.message.role === "assistant") { - const toolCall = entry.message.content.find( - (block): block is AgentToolCall => block.type === "toolCall" && block.id === toolCallId, - ); - if (!toolCall) return undefined; - if (toolCall.name !== "ask") return undefined; - return recoverAskQuestions(toolCall.arguments); + if (!entry) return undefined; + if (entry.type === "message") { + if (entry.message.role === "assistant") { + const toolCall = entry.message.content.find( + (block): block is AgentToolCall => block.type === "toolCall" && block.id === toolCallId, + ); + if (!toolCall) return undefined; + if (toolCall.name !== "ask") return undefined; + return recoverAskQuestions(toolCall.arguments); + } + if (entry.message.role === "user") return undefined; } - if (entry.message.role !== "toolResult") return undefined; current = entry.parentId; } return undefined; diff --git a/packages/coding-agent/test/agent-session-tree-ask-reanswer.test.ts b/packages/coding-agent/test/agent-session-tree-ask-reanswer.test.ts index d149553c8..94a0115af 100644 --- a/packages/coding-agent/test/agent-session-tree-ask-reanswer.test.ts +++ b/packages/coding-agent/test/agent-session-tree-ask-reanswer.test.ts @@ -274,6 +274,36 @@ describe("AgentSession tree navigation onto an ask toolResult", () => { } }); + it("(g2) recovers reopenAsk questions past a tool_execution_start bookkeeping entry", async () => { + const ctx = await createTestSession({ inMemory: true }); + try { + const { session, sessionManager } = ctx; + + // Real persisted sessions insert a `custom` (not `custom_message`) + // `tool_execution_start` entry between the assistant message and every + // toolResult — `#recordToolExecutionStart()` appends it before the + // tool actually runs. The ancestor walk must skip this bookkeeping + // entry too, not just other `message`-typed toolResults. + sessionManager.appendMessage(userMsg("please deploy")); + const askCallId = "ask-call-1"; + sessionManager.appendMessage(toolCallMsg(askCallId, "ask", { questions: ORIGINAL_QUESTIONS })); + sessionManager.appendCustomEntry("tool_execution_start", { toolCallId: askCallId, toolName: "ask" }); + const trAskId = sessionManager.appendMessage( + toolResultMsg(askCallId, "ask", "User selected: staging", staleAnswerResult().details), + ); + sessionManager.appendMessage(assistantMsg("deploying to staging")); + + const result = await session.navigateTree(trAskId, { allowAskReopen: true }); + + expect(result.cancelled).toBe(false); + expect(result.reopenAsk).toBeDefined(); + expect(result.reopenAsk?.toolCallId).toBe(askCallId); + expect(result.reopenAsk?.questions).toEqual(ORIGINAL_QUESTIONS); + } finally { + await ctx.cleanup(); + } + }); + it("(h) a reanswer completion summarizes the abandoned branch including the replaced answer", async () => { const capturedEntryIds: string[][] = []; const extensionRunner = {