Skip to content

Commit 654036e

Browse files
committed
🤖 fix: finalize task tool calls from history
Finalize pending task tool calls before appending the synthetic report message so restart scenarios can update history-only tool parts and emit tool-call-end. Add a regression test for the history-only path. Signed-off-by: Thomas Kosiewski <tk@coder.com> --- _Generated with `codex cli` • Model: `gpt-5.2` • Thinking: `xhigh`_ <!-- mux-attribution: model=gpt-5.2 thinking=xhigh --> Change-Id: I2e95fa8815f0d33bf1548fde44d7edd9d339e2d6
1 parent 1c1ae0d commit 654036e

File tree

2 files changed

+184
-3
lines changed

2 files changed

+184
-3
lines changed

src/node/services/taskService.test.ts

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,187 @@ describe("TaskService", () => {
469469
expect(emit).toHaveBeenCalled();
470470
});
471471

472+
test("agent_report finalizes pending task tool output from history when parent has no partial", async () => {
473+
const config = new Config(rootDir);
474+
475+
const projectPath = path.join(rootDir, "repo");
476+
const parentId = "parent-111";
477+
const childId = "child-222";
478+
479+
await config.saveConfig({
480+
projects: new Map([
481+
[
482+
projectPath,
483+
{
484+
workspaces: [
485+
{ path: path.join(projectPath, "parent"), id: parentId, name: "parent" },
486+
{
487+
path: path.join(projectPath, "child"),
488+
id: childId,
489+
name: "agent_explore_child",
490+
parentWorkspaceId: parentId,
491+
agentType: "explore",
492+
taskStatus: "running",
493+
},
494+
],
495+
},
496+
],
497+
]),
498+
taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 },
499+
});
500+
501+
const historyService = new HistoryService(config);
502+
const partialService = new PartialService(config, historyService);
503+
504+
// Restart scenario: parent has no partial.json, only history containing the pending tool call.
505+
const parentHistoryMessage = createMuxMessage(
506+
"assistant-parent-history",
507+
"assistant",
508+
"Waiting on subagent…",
509+
{ timestamp: Date.now() },
510+
[
511+
{
512+
type: "dynamic-tool",
513+
toolCallId: "task-call-1",
514+
toolName: "task",
515+
input: { subagent_type: "explore", prompt: "do the thing" },
516+
state: "input-available",
517+
},
518+
]
519+
);
520+
const appendParentHistory = await historyService.appendToHistory(
521+
parentId,
522+
parentHistoryMessage
523+
);
524+
expect(appendParentHistory.success).toBe(true);
525+
526+
const childPartial = createMuxMessage(
527+
"assistant-child-partial",
528+
"assistant",
529+
"",
530+
{ timestamp: Date.now() },
531+
[
532+
{
533+
type: "dynamic-tool",
534+
toolCallId: "agent-report-call-1",
535+
toolName: "agent_report",
536+
input: { reportMarkdown: "Hello from child", title: "Result" },
537+
state: "output-available",
538+
output: { success: true },
539+
},
540+
]
541+
);
542+
const writeChildPartial = await partialService.writePartial(childId, childPartial);
543+
expect(writeChildPartial.success).toBe(true);
544+
545+
const aiService: AIService = {
546+
isStreaming: mock(() => false),
547+
on: mock(() => undefined),
548+
off: mock(() => undefined),
549+
} as unknown as AIService;
550+
551+
const sendMessage = mock(() => Promise.resolve(Ok(undefined)));
552+
const resumeStream = mock(() => Promise.resolve(Ok(undefined)));
553+
const remove = mock(() => Promise.resolve(Ok(undefined)));
554+
const emit = mock(() => true);
555+
556+
const workspaceService: WorkspaceService = {
557+
sendMessage,
558+
resumeStream,
559+
remove,
560+
emit,
561+
} as unknown as WorkspaceService;
562+
563+
const initStateManager: InitStateManager = {
564+
startInit: mock(() => undefined),
565+
appendOutput: mock(() => undefined),
566+
endInit: mock(() => Promise.resolve()),
567+
} as unknown as InitStateManager;
568+
569+
const taskService = new TaskService(
570+
config,
571+
historyService,
572+
partialService,
573+
aiService,
574+
workspaceService,
575+
initStateManager
576+
);
577+
578+
const internal = taskService as unknown as {
579+
handleAgentReport: (event: {
580+
type: "tool-call-end";
581+
workspaceId: string;
582+
toolName: string;
583+
}) => Promise<void>;
584+
};
585+
await internal.handleAgentReport({
586+
type: "tool-call-end",
587+
workspaceId: childId,
588+
toolName: "agent_report",
589+
});
590+
591+
const parentPartial = await partialService.readPartial(parentId);
592+
expect(parentPartial).toBeNull();
593+
594+
const parentHistory = await historyService.getHistory(parentId);
595+
expect(parentHistory.success).toBe(true);
596+
if (parentHistory.success) {
597+
const msg = parentHistory.data.find((m) => m.id === "assistant-parent-history") ?? null;
598+
expect(msg).not.toBeNull();
599+
if (msg) {
600+
const toolPart = msg.parts.find(
601+
(p) =>
602+
p &&
603+
typeof p === "object" &&
604+
"type" in p &&
605+
(p as { type?: unknown }).type === "dynamic-tool"
606+
) as unknown as
607+
| {
608+
toolName: string;
609+
state: string;
610+
output?: unknown;
611+
}
612+
| undefined;
613+
expect(toolPart?.toolName).toBe("task");
614+
expect(toolPart?.state).toBe("output-available");
615+
expect(toolPart?.output && typeof toolPart.output === "object").toBe(true);
616+
expect(JSON.stringify(toolPart?.output)).toContain("Hello from child");
617+
}
618+
619+
const combined = parentHistory.data
620+
.filter((m) => m.role === "assistant")
621+
.flatMap((m) => m.parts)
622+
.map((p) =>
623+
p &&
624+
typeof p === "object" &&
625+
"type" in p &&
626+
(p as { type?: unknown }).type === "text" &&
627+
"text" in p
628+
? (p as { text?: unknown }).text
629+
: ""
630+
)
631+
.join("");
632+
expect(combined).toContain("Hello from child");
633+
}
634+
635+
const emitCalls = (emit as unknown as { mock: { calls: Array<[string, unknown]> } }).mock.calls;
636+
const hasTaskToolCallEnd = emitCalls.some((call) => {
637+
const [eventName, payload] = call;
638+
if (eventName !== "chat") return false;
639+
if (!payload || typeof payload !== "object") return false;
640+
const maybePayload = payload as { workspaceId?: unknown; message?: unknown };
641+
if (maybePayload.workspaceId !== parentId) return false;
642+
const message = maybePayload.message;
643+
if (!message || typeof message !== "object") return false;
644+
const maybeMessage = message as { type?: unknown; toolName?: unknown };
645+
return maybeMessage.type === "tool-call-end" && maybeMessage.toolName === "task";
646+
});
647+
expect(hasTaskToolCallEnd).toBe(true);
648+
649+
expect(remove).toHaveBeenCalled();
650+
expect(resumeStream).toHaveBeenCalled();
651+
});
652+
472653
test("missing agent_report triggers one reminder, then posts fallback output and cleans up", async () => {
473654
const config = new Config(rootDir);
474655

src/node/services/taskService.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -832,6 +832,9 @@ export class TaskService {
832832
const titlePrefix = report.title ? `${report.title}` : `Subagent (${agentType}) report`;
833833
const markdown = `### ${titlePrefix}\n\n${report.reportMarkdown}`;
834834

835+
// Restart-safe: if the parent has a pending task tool call in partial/history, finalize it with the report.
836+
await this.tryFinalizePendingTaskToolCall(parentWorkspaceId, report, agentType);
837+
835838
const messageId = `assistant-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
836839
const reportMessage = createMuxMessage(messageId, "assistant", markdown, {
837840
timestamp: Date.now(),
@@ -855,9 +858,6 @@ export class TaskService {
855858
workspaceId: parentWorkspaceId,
856859
message: { ...reportMessage, type: "message" },
857860
});
858-
859-
// Restart-safe: if the parent has a pending task tool call in partial/history, finalize it with the report.
860-
await this.tryFinalizePendingTaskToolCall(parentWorkspaceId, report, agentType);
861861
}
862862

863863
private async tryFinalizePendingTaskToolCall(

0 commit comments

Comments
 (0)