Skip to content

Commit 0c6d24a

Browse files
authored
🤖 fix: honor agent_report on stream-end (#1302)
1 parent 701593d commit 0c6d24a

File tree

3 files changed

+251
-39
lines changed

3 files changed

+251
-39
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"lockfileVersion": 1,
3+
"configVersion": 0,
34
"workspaces": {
45
"": {
56
"name": "mux",

src/node/services/taskService.test.ts

Lines changed: 157 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { PartialService } from "@/node/services/partialService";
1010
import { TaskService } from "@/node/services/taskService";
1111
import { createRuntime } from "@/node/runtime/runtimeFactory";
1212
import { Ok, Err, type Result } from "@/common/types/result";
13+
import type { StreamEndEvent } from "@/common/types/stream";
1314
import { createMuxMessage } from "@/common/types/message";
1415
import type { WorkspaceMetadata } from "@/common/types/workspace";
1516
import type { AIService } from "@/node/services/aiService";
@@ -896,10 +897,16 @@ describe("TaskService", () => {
896897
const { taskService } = createTaskServiceHarness(config, { aiService, workspaceService });
897898

898899
const internal = taskService as unknown as {
899-
handleStreamEnd: (event: { type: "stream-end"; workspaceId: string }) => Promise<void>;
900+
handleStreamEnd: (event: StreamEndEvent) => Promise<void>;
900901
};
901902

902-
await internal.handleStreamEnd({ type: "stream-end", workspaceId: rootWorkspaceId });
903+
await internal.handleStreamEnd({
904+
type: "stream-end",
905+
workspaceId: rootWorkspaceId,
906+
messageId: "assistant-root",
907+
metadata: { model: "openai:gpt-5.2" },
908+
parts: [],
909+
});
903910

904911
expect(resumeStream).toHaveBeenCalledTimes(1);
905912
expect(resumeStream).toHaveBeenCalledWith(
@@ -1261,9 +1268,15 @@ describe("TaskService", () => {
12611268
const { taskService } = createTaskServiceHarness(config, { workspaceService });
12621269

12631270
const internal = taskService as unknown as {
1264-
handleStreamEnd: (event: { type: "stream-end"; workspaceId: string }) => Promise<void>;
1271+
handleStreamEnd: (event: StreamEndEvent) => Promise<void>;
12651272
};
1266-
await internal.handleStreamEnd({ type: "stream-end", workspaceId: parentTaskId });
1273+
await internal.handleStreamEnd({
1274+
type: "stream-end",
1275+
workspaceId: parentTaskId,
1276+
messageId: "assistant-parent-task",
1277+
metadata: { model: "openai:gpt-4o-mini" },
1278+
parts: [],
1279+
});
12671280

12681281
expect(sendMessage).not.toHaveBeenCalled();
12691282

@@ -1316,9 +1329,15 @@ describe("TaskService", () => {
13161329
const { taskService } = createTaskServiceHarness(config, { workspaceService });
13171330

13181331
const internal = taskService as unknown as {
1319-
handleStreamEnd: (event: { type: "stream-end"; workspaceId: string }) => Promise<void>;
1332+
handleStreamEnd: (event: StreamEndEvent) => Promise<void>;
13201333
};
1321-
await internal.handleStreamEnd({ type: "stream-end", workspaceId: parentTaskId });
1334+
await internal.handleStreamEnd({
1335+
type: "stream-end",
1336+
workspaceId: parentTaskId,
1337+
messageId: "assistant-parent-task",
1338+
metadata: { model: "openai:gpt-4o-mini" },
1339+
parts: [],
1340+
});
13221341

13231342
expect(sendMessage).not.toHaveBeenCalled();
13241343

@@ -1675,6 +1694,123 @@ describe("TaskService", () => {
16751694
expect(resumeStream).toHaveBeenCalled();
16761695
});
16771696

1697+
test("uses agent_report from stream-end parts instead of fallback", async () => {
1698+
const config = await createTestConfig(rootDir);
1699+
1700+
const projectPath = path.join(rootDir, "repo");
1701+
const parentId = "parent-111";
1702+
const childId = "child-222";
1703+
1704+
await config.saveConfig({
1705+
projects: new Map([
1706+
[
1707+
projectPath,
1708+
{
1709+
workspaces: [
1710+
{ path: path.join(projectPath, "parent"), id: parentId, name: "parent" },
1711+
{
1712+
path: path.join(projectPath, "child"),
1713+
id: childId,
1714+
name: "agent_explore_child",
1715+
parentWorkspaceId: parentId,
1716+
agentType: "explore",
1717+
taskStatus: "awaiting_report",
1718+
taskModelString: "openai:gpt-4o-mini",
1719+
},
1720+
],
1721+
},
1722+
],
1723+
]),
1724+
taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 },
1725+
});
1726+
1727+
const { aiService } = createAIServiceMocks(config);
1728+
const { workspaceService, sendMessage, resumeStream, remove } = createWorkspaceServiceMocks();
1729+
const { partialService, taskService } = createTaskServiceHarness(config, {
1730+
aiService,
1731+
workspaceService,
1732+
});
1733+
1734+
// Simulate the "second attempt" state (the task was already reminded).
1735+
(taskService as unknown as { remindedAwaitingReport: Set<string> }).remindedAwaitingReport.add(
1736+
childId
1737+
);
1738+
1739+
const parentPartial = createMuxMessage(
1740+
"assistant-parent-partial",
1741+
"assistant",
1742+
"Waiting on subagent…",
1743+
{ timestamp: Date.now() },
1744+
[
1745+
{
1746+
type: "dynamic-tool",
1747+
toolCallId: "task-call-1",
1748+
toolName: "task",
1749+
input: { subagent_type: "explore", prompt: "do the thing", title: "Test task" },
1750+
state: "input-available",
1751+
},
1752+
]
1753+
);
1754+
const writeParentPartial = await partialService.writePartial(parentId, parentPartial);
1755+
expect(writeParentPartial.success).toBe(true);
1756+
1757+
const internal = taskService as unknown as {
1758+
handleStreamEnd: (event: unknown) => Promise<void>;
1759+
};
1760+
1761+
await internal.handleStreamEnd({
1762+
type: "stream-end",
1763+
workspaceId: childId,
1764+
messageId: "assistant-child-output",
1765+
metadata: { model: "openai:gpt-4o-mini" },
1766+
parts: [
1767+
{
1768+
type: "dynamic-tool",
1769+
toolCallId: "agent-report-call-1",
1770+
toolName: "agent_report",
1771+
input: { reportMarkdown: "Hello from child", title: "Result" },
1772+
state: "output-available",
1773+
output: { success: true },
1774+
},
1775+
],
1776+
});
1777+
1778+
expect(sendMessage).not.toHaveBeenCalled();
1779+
1780+
const updatedParentPartial = await partialService.readPartial(parentId);
1781+
expect(updatedParentPartial).not.toBeNull();
1782+
if (updatedParentPartial) {
1783+
const toolPart = updatedParentPartial.parts.find(
1784+
(p) =>
1785+
p &&
1786+
typeof p === "object" &&
1787+
"type" in p &&
1788+
(p as { type?: unknown }).type === "dynamic-tool"
1789+
) as unknown as
1790+
| {
1791+
toolName: string;
1792+
state: string;
1793+
output?: unknown;
1794+
}
1795+
| undefined;
1796+
expect(toolPart?.toolName).toBe("task");
1797+
expect(toolPart?.state).toBe("output-available");
1798+
const outputJson = JSON.stringify(toolPart?.output);
1799+
expect(outputJson).toContain("Hello from child");
1800+
expect(outputJson).toContain("Result");
1801+
expect(outputJson).not.toContain("fallback");
1802+
}
1803+
1804+
const postCfg = config.loadConfigOrDefault();
1805+
const ws = Array.from(postCfg.projects.values())
1806+
.flatMap((p) => p.workspaces)
1807+
.find((w) => w.id === childId);
1808+
expect(ws?.taskStatus).toBe("reported");
1809+
1810+
expect(remove).toHaveBeenCalled();
1811+
expect(resumeStream).toHaveBeenCalled();
1812+
});
1813+
16781814
test("missing agent_report triggers one reminder, then posts fallback output and cleans up", async () => {
16791815
const config = await createTestConfig(rootDir);
16801816

@@ -1741,10 +1877,16 @@ describe("TaskService", () => {
17411877
expect(appendChildHistory.success).toBe(true);
17421878

17431879
const internal = taskService as unknown as {
1744-
handleStreamEnd: (event: { type: "stream-end"; workspaceId: string }) => Promise<void>;
1880+
handleStreamEnd: (event: StreamEndEvent) => Promise<void>;
17451881
};
17461882

1747-
await internal.handleStreamEnd({ type: "stream-end", workspaceId: childId });
1883+
await internal.handleStreamEnd({
1884+
type: "stream-end",
1885+
workspaceId: childId,
1886+
messageId: "assistant-child-output",
1887+
metadata: { model: "openai:gpt-4o-mini" },
1888+
parts: [],
1889+
});
17481890
expect(sendMessage).toHaveBeenCalled();
17491891

17501892
const midCfg = config.loadConfigOrDefault();
@@ -1753,7 +1895,13 @@ describe("TaskService", () => {
17531895
.find((w) => w.id === childId);
17541896
expect(midWs?.taskStatus).toBe("awaiting_report");
17551897

1756-
await internal.handleStreamEnd({ type: "stream-end", workspaceId: childId });
1898+
await internal.handleStreamEnd({
1899+
type: "stream-end",
1900+
workspaceId: childId,
1901+
messageId: "assistant-child-output",
1902+
metadata: { model: "openai:gpt-4o-mini" },
1903+
parts: [],
1904+
});
17571905

17581906
const emitCalls = (emit as unknown as { mock: { calls: Array<[string, unknown]> } }).mock.calls;
17591907
const metadataEmitsForChild = emitCalls.filter((call) => {

0 commit comments

Comments
 (0)