fix(coding-agent): keep running async task handles during rebuild dedup

The rebuild dedup dropped any preserved pendingTools component whose toolCallId
had a persisted toolResult. A background task's initial async.state=="running"
result is persisted while EventController#handleToolExecutionEnd deliberately
keeps its component in pendingTools so a later tool_execution_update/_end can
settle it. Dropping that still-live handle stranded those updates on the running
snapshot. Only terminal results are now owned by the replay; running async
handles stay preserved. Added a regression covering the running-task case.
This commit is contained in:
roboomp
2026-07-24 13:53:36 +00:00
parent 759e551e8c
commit 484fa319eb
2 changed files with 85 additions and 0 deletions
@@ -1756,6 +1756,16 @@ export class InteractiveMode implements InteractiveModeContext {
if (message.role !== "toolResult") continue;
const resolved = livePendingTools.get(message.toolCallId);
if (!resolved) continue;
// A background task's initial `async.state === "running"` result is
// persisted while `EventController#handleToolExecutionEnd` deliberately
// keeps its component in `pendingTools` so a later
// `tool_execution_update`/`_end` settles it. Such a handle is still
// live — dropping it would strand those updates on the running snapshot
// — so keep it and let the live component retain ownership; only
// terminal results are owned by the replay. (Cast mirrors the async
// detail reads in tool-execution.ts / event-controller.ts.)
const details = message.details as { async?: { state?: string } } | undefined;
if (details?.async?.state === "running") continue;
livePendingTools.delete(message.toolCallId);
const index = liveComponents.indexOf(resolved as unknown as Component);
if (index >= 0) liveComponents.splice(index, 1);
@@ -208,4 +208,79 @@ describe("issue #6516 — tool output appears twice", () => {
// tool's result still lands in the on-screen block.
expect(mode.pendingTools.get("call-1")).toBe(live);
});
it("keeps a still-running background task's live handle across a rebuild", () => {
const runningDetails = { async: { state: "running", jobId: "job-1", type: "task" } };
const entries: SessionEntry[] = [
{
type: "message",
id: "m1",
parentId: null,
timestamp: Date.now(),
message: { role: "user", content: [{ type: "text", text: "spawn it" }], timestamp: 1 },
},
{
type: "message",
id: "m2",
parentId: "m1",
timestamp: Date.now(),
message: {
role: "assistant",
content: [
{ type: "toolCall", id: "call-1", name: "task", arguments: { description: "run", prompt: "go" } },
],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
usage,
stopReason: "toolUse",
timestamp: 2,
},
},
{
type: "message",
id: "m3",
parentId: "m2",
timestamp: Date.now(),
message: {
role: "toolResult",
toolCallId: "call-1",
toolName: "task",
content: [{ type: "text", text: "running…" }],
details: runningDetails,
isError: false,
timestamp: 3,
},
},
] as unknown as SessionEntry[];
Object.defineProperty(session, "isStreaming", { configurable: true, get: () => true });
vi.spyOn(session, "buildTranscriptSessionContext").mockReturnValue(
buildSessionContext(entries, undefined, undefined, { transcript: true }),
);
const live = new ToolExecutionComponent(
"task",
{ description: "run", prompt: "go" },
{},
undefined,
mode.ui,
tempDir.path(),
"call-1",
);
live.updateResult(
{ content: [{ type: "text", text: "running…" }], details: runningDetails, isError: false },
true,
"call-1",
);
created.push(live);
mode.chatContainer.addChild(live);
mode.pendingTools.set("call-1", live);
mode.rebuildChatFromMessages();
// The still-running task's live handle must survive the rebuild so a later
// tool_execution_update/_end settles it instead of stranding on "running".
expect(mode.pendingTools.get("call-1")).toBe(live);
});
});