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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user