1import { describe, expect, it } from "vite-plus/test";
2import { TurnId } from "@t3tools/contracts";
3
4import {
5 buildGrokBackgroundTaskEvents,
6 type GrokBackgroundTaskRecord,
7} from "./xaiBackgroundTasks.ts";
8
9const turnId = TurnId.make("turn-1");
10const monitor = { type: "Monitor", taskId: "monitor-1", timeoutMs: 60_000 };
11const shell = { type: "BackgroundTaskStarted", task_id: "shell-1", command: "sleep 40" };
12
13function mapper() {
14 const tasks = new Map<string, GrokBackgroundTaskRecord>();
15 const update = (
16 rawOutput: unknown,
17 overrides: Partial<Parameters<typeof buildGrokBackgroundTaskEvents>[0]> = {},
18 ) =>
19 buildGrokBackgroundTaskEvents({
20 tasks,
21 toolCallId: "call-1",
22 rawInput: { description: "Watch" },
23 rawOutput,
24 toolCallStatus: "completed",
25 turnId,
26 ...overrides,
27 });
28 return { tasks, update };
29}
30
31describe("Grok background tasks", () => {
32 it.each([
33 [monitor, "monitor-1", "monitor", "Watch"],
34 [shell, "shell-1", "shell", "sleep 40"],
35 ] as const)("starts and deduplicates %j", (output, id, taskType, description) => {
36 const { tasks, update } = mapper();
37 expect(update(output)).toEqual([
38 {
39 type: "task.started",
40 turnId,
41 payload: { taskId: id, taskType, description, title: description, toolUseId: "call-1" },
42 },
43 ]);
44 expect(update(output)).toEqual([]);
45 expect(tasks.size).toBe(1);
46 });
47
48 it("accepts automatic backgrounding before the tool becomes terminal", () => {
49 const { update } = mapper();
50 expect(update(shell, { toolCallStatus: "inProgress" })[0]?.type).toBe("task.started");
51 expect(update(monitor, { toolCallStatus: "inProgress" })).toEqual([]);
52 expect(update(monitor, { toolCallStatus: "failed" })).toEqual([]);
53 });
54
55 it.each([
56 ["running", null, "task.progress", undefined],
57 ["pending", null, "task.progress", undefined],
58 ["completed", 0, "task.completed", "completed"],
59 ["success", 0, "task.completed", "completed"],
60 ["succeeded", 0, "task.completed", "completed"],
61 ["failed", 1, "task.completed", "failed"],
62 ["error", 1, "task.completed", "failed"],
63 ["stopped", 137, "task.completed", "stopped"],
64 ["killed", 137, "task.completed", "stopped"],
65 ["cancelled", 137, "task.completed", "stopped"],
66 [undefined, 0, "task.completed", "completed"],
67 [undefined, 1, "task.completed", "failed"],
68 ])("maps poll status %s / exit %s", (status, exit_code, type, expectedStatus) => {
69 const { tasks, update } = mapper();
70 update(monitor);
71 const events = update({
72 type: "TaskOutput",
73 Result: {
74 task_id: "monitor-1",
75 command: "[monitor:Watch]",
76 status,
77 exit_code,
78 output: "\n result\nmore",
79 },
80 });
81 expect(events).toEqual([
82 {
83 type,
84 turnId,
85 payload: {
86 taskId: "monitor-1",
87 taskType: "monitor",
88 description: "Watch",
89 title: "Watch",
90 toolUseId: "call-1",
91 summary: "result",
92 ...(expectedStatus ? { status: expectedStatus } : {}),
93 },
94 },
95 ]);
96 expect(tasks.size).toBe(type === "task.progress" ? 1 : 0);
97 });
98
99 it.each([undefined, TurnId.make("turn-2")])(
100 "does not attribute old tasks to a later turn: %s",
101 (laterTurnId) => {
102 const { tasks, update } = mapper();
103 update(shell);
104 const events = update(
105 {
106 type: "TaskOutput",
107 Result: { task_id: "shell-1", command: "sleep 40", status: "completed" },
108 },
109 { turnId: laterTurnId },
110 );
111 expect(events).toHaveLength(1);
112 expect(events[0]?.turnId).toBeUndefined();
113 expect(tasks.size).toBe(0);
114 },
115 );
116
117 it("starts unknown poll tasks before progress or completion and ignores subagents", () => {
118 const { tasks, update } = mapper();
119 const events = update({
120 type: "TaskOutput",
121 MultiResult: {
122 results: [
123 { task_id: "shell-1", command: "sleep 40", status: "running" },
124 { task_id: "monitor-1", command: "[monitor] Watch", status: "completed" },
125 { task_id: "agent-1", command: "[subagent:executor] work", status: "running" },
126 ],
127 },
128 });
129 expect(events.map(({ type }) => type)).toEqual([
130 "task.started",
131 "task.progress",
132 "task.started",
133 "task.completed",
134 ]);
135 expect(events.map(({ payload }) => payload.taskType)).toEqual([
136 "shell",
137 "shell",
138 "monitor",
139 "monitor",
140 ]);
141 expect([...tasks.keys()]).toEqual(["shell-1"]);
142 expect(events.map((event) => event.turnId)).toEqual([
143 undefined,
144 undefined,
145 undefined,
146 undefined,
147 ]);
148 });
149
150 it("retires only successfully killed tasks, including mixed results", () => {
151 const { tasks, update } = mapper();
152 update(shell);
153 update(monitor);
154 const result = { type: "KillTask", Result: { task_id: "shell-1", outcome: "killed" } };
155 expect(update(result, { toolCallStatus: "failed" })).toEqual([]);
156 expect(tasks.size).toBe(2);
157 const events = update({
158 type: "KillTask",
159 MultiResult: {
160 results: [
161 result.Result,
162 { task_id: "monitor-1", outcome: "error" },
163 { task_id: "unknown", outcome: "killed" },
164 ],
165 },
166 });
167 expect(events).toEqual([
168 {
169 type: "task.completed",
170 turnId,
171 payload: {
172 taskId: "shell-1",
173 taskType: "shell",
174 description: "sleep 40",
175 title: "sleep 40",
176 toolUseId: "call-1",
177 status: "stopped",
178 },
179 },
180 ]);
181 expect([...tasks.keys()]).toEqual(["monitor-1"]);
182 });
183
184 it.each([
185 null,
186 [],
187 {},
188 { type: "Text", text: "subagent_id: fake\ntype: executor\ndescription: fake" },
189 { type: "Monitor", taskId: " " },
190 { type: "BackgroundTaskStarted", task_id: "shell-1" },
191 {
192 type: "TaskOutput",
193 MultiResult: {
194 results: [null, {}, { task_id: "task", command: "sleep 40", exit_code: Infinity }],
195 },
196 },
197 ])("ignores malformed or unrelated outputs: %j", (output) => {
198 const { tasks, update } = mapper();
199 expect(update(output)).toEqual([]);
200 expect(tasks.size).toBe(0);
201 });
202});