| 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | import { TurnId } from "@t3tools/contracts"; |
| 3 | |
| 4 | import { |
| 5 | buildGrokBackgroundTaskEvents, |
| 6 | type GrokBackgroundTaskRecord, |
| 7 | } from "./xaiBackgroundTasks.ts"; |
| 8 | |
| 9 | const turnId = TurnId.make("turn-1"); |
| 10 | const monitor = { type: "Monitor", taskId: "monitor-1", timeoutMs: 60_000 }; |
| 11 | const shell = { type: "BackgroundTaskStarted", task_id: "shell-1", command: "sleep 40" }; |
| 12 | |
| 13 | function 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 | |
| 31 | describe("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 | }); |