| author | |
| committer | |
| log | 3138f5716098a331f9a7d4cfc1bcd07118967a83 |
| tree | f33abec8ddc4e60fe6c4f0b3d60bdf48de20caf6 |
| parent | 0c5771d60a8ef2db34dfbbc142f7524badc829e0 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>5 files changed, 549 insertions(+), 2 deletions(-)
apps/server/scripts/acp-mock-agent.ts+104| ... | @@ -30,6 +30,8 @@ const emitXAiAskUserQuestionThenHang = | ... | @@ -30,6 +30,8 @@ const emitXAiAskUserQuestionThenHang = |
| 30 | const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1"; | 30 | const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1"; |
| 31 | const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1"; | 31 | const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1"; |
| 32 | const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1"; | 32 | const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1"; |
| 33 | const emitGrokMonitorPostTurnPoll = process.env.T3_ACP_EMIT_GROK_MONITOR_POST_TURN_POLL === "1"; | ||
| 34 | const emitGrokBackgroundTaskStarted = process.env.T3_ACP_EMIT_GROK_BACKGROUND_TASK_STARTED === "1"; | ||
| 33 | const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; | 35 | const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; |
| 34 | const waitForResumeRelease = process.env.T3_ACP_WAIT_FOR_RESUME_RELEASE === "1"; | 36 | const waitForResumeRelease = process.env.T3_ACP_WAIT_FOR_RESUME_RELEASE === "1"; |
| 35 | const completeFirstPromptOnCancel = process.env.T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL === "1"; | 37 | const completeFirstPromptOnCancel = process.env.T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL === "1"; |
| ... | @@ -835,6 +837,108 @@ const program = Effect.gen(function* () { | ... | @@ -835,6 +837,108 @@ const program = Effect.gen(function* () { |
| 835 | return yield* Effect.never; | 837 | return yield* Effect.never; |
| 836 | } | 838 | } |
| 837 | 839 | ||
| 840 | if (emitGrokMonitorPostTurnPoll) { | ||
| 841 | const monitorCallId = "call-monitor-1"; | ||
| 842 | const pollCallId = "call-monitor-poll-1"; | ||
| 843 | const taskId = "01a05f41-5107-7550-821e-79e8d1cd7687"; | ||
| 844 | const description = "Watch count-sheet Typst unit until done"; | ||
| 845 | writeJsonRpcNotification("session/update", { | ||
| 846 | sessionId: requestedSessionId, | ||
| 847 | update: { | ||
| 848 | sessionUpdate: "tool_call", | ||
| 849 | toolCallId: monitorCallId, | ||
| 850 | title: "monitor", | ||
| 851 | kind: "other", | ||
| 852 | status: "pending", | ||
| 853 | rawInput: { description }, | ||
| 854 | _meta: { | ||
| 855 | "x.ai/tool": { version: 1, name: "monitor", kind: "task", namespace: "grok_build" }, | ||
| 856 | }, | ||
| 857 | }, | ||
| 858 | }); | ||
| 859 | writeJsonRpcNotification("session/update", { | ||
| 860 | sessionId: requestedSessionId, | ||
| 861 | update: { | ||
| 862 | sessionUpdate: "tool_call_update", | ||
| 863 | toolCallId: monitorCallId, | ||
| 864 | status: "completed", | ||
| 865 | rawInput: { description }, | ||
| 866 | rawOutput: { | ||
| 867 | type: "Monitor", | ||
| 868 | taskId, | ||
| 869 | timeoutMs: 36_000_000, | ||
| 870 | }, | ||
| 871 | }, | ||
| 872 | }); | ||
| 873 | writeJsonRpcNotification("_x.ai/session/prompt_complete", { | ||
| 874 | sessionId: requestedSessionId, | ||
| 875 | promptId: promptIdFromRequestMeta(request) ?? "mock-xai-prompt-1", | ||
| 876 | stopReason: "end_turn", | ||
| 877 | agentResult: null, | ||
| 878 | }); | ||
| 879 | yield* Effect.sleep("120 millis"); | ||
| 880 | writeJsonRpcNotification("session/update", { | ||
| 881 | sessionId: requestedSessionId, | ||
| 882 | update: { | ||
| 883 | sessionUpdate: "tool_call", | ||
| 884 | toolCallId: pollCallId, | ||
| 885 | title: "get_command_or_subagent_output", | ||
| 886 | kind: "other", | ||
| 887 | status: "completed", | ||
| 888 | rawInput: { variant: "TaskOutput", task_ids: [taskId], timeout_ms: 0 }, | ||
| 889 | rawOutput: { | ||
| 890 | type: "TaskOutput", | ||
| 891 | Result: { | ||
| 892 | task_id: taskId, | ||
| 893 | command: `[monitor] ${description}`, | ||
| 894 | status: "completed", | ||
| 895 | exit_code: 0, | ||
| 896 | output: "Monitor finished.", | ||
| 897 | }, | ||
| 898 | }, | ||
| 899 | }, | ||
| 900 | }); | ||
| 901 | return yield* Effect.never; | ||
| 902 | } | ||
| 903 | |||
| 904 | if (emitGrokBackgroundTaskStarted) { | ||
| 905 | const toolCallId = "call-fb9d0000-0000-0000-0000-000000000026"; | ||
| 906 | const command = "sleep 40; echo done-a"; | ||
| 907 | writeJsonRpcNotification("session/update", { | ||
| 908 | sessionId: requestedSessionId, | ||
| 909 | update: { | ||
| 910 | sessionUpdate: "tool_call", | ||
| 911 | toolCallId, | ||
| 912 | title: "run_terminal_command", | ||
| 913 | kind: "execute", | ||
| 914 | status: "in_progress", | ||
| 915 | rawInput: { command }, | ||
| 916 | }, | ||
| 917 | }); | ||
| 918 | writeJsonRpcNotification("session/update", { | ||
| 919 | sessionId: requestedSessionId, | ||
| 920 | update: { | ||
| 921 | sessionUpdate: "tool_call_update", | ||
| 922 | toolCallId, | ||
| 923 | status: "completed", | ||
| 924 | rawOutput: { | ||
| 925 | type: "BackgroundTaskStarted", | ||
| 926 | task_id: toolCallId, | ||
| 927 | task_type: "bash", | ||
| 928 | status: "running", | ||
| 929 | command, | ||
| 930 | }, | ||
| 931 | }, | ||
| 932 | }); | ||
| 933 | writeJsonRpcNotification("_x.ai/session/prompt_complete", { | ||
| 934 | sessionId: requestedSessionId, | ||
| 935 | promptId: promptIdFromRequestMeta(request) ?? "mock-xai-prompt-1", | ||
| 936 | stopReason: "end_turn", | ||
| 937 | agentResult: null, | ||
| 938 | }); | ||
| 939 | return yield* Effect.never; | ||
| 940 | } | ||
| 941 | |||
| 838 | if (emitInterleavedAssistantToolCalls) { | 942 | if (emitInterleavedAssistantToolCalls) { |
| 839 | const toolCallId = "tool-call-1"; | 943 | const toolCallId = "tool-call-1"; |
| 840 | 944 |
apps/server/src/provider/Layers/GrokAdapter.test.ts+62-2| ... | @@ -24,8 +24,10 @@ import { | ... | @@ -24,8 +24,10 @@ import { |
| 24 | TurnId, | 24 | TurnId, |
| 25 | type ProviderRuntimeEvent, | 25 | type ProviderRuntimeEvent, |
| 26 | } from "@t3tools/contracts"; | 26 | } from "@t3tools/contracts"; |
| 27 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 27 | 28 | ||
| 28 | import { ServerConfig } from "../../config.ts"; | 29 | import { ServerConfig } from "../../config.ts"; |
| 30 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; | ||
| 29 | import { | 31 | import { |
| 30 | grokPromptSettlementBelongsToContext, | 32 | grokPromptSettlementBelongsToContext, |
| 31 | isGrokEnterPlanModeToolCall, | 33 | isGrokEnterPlanModeToolCall, |
| ... | @@ -33,8 +35,7 @@ import { | ... | @@ -33,8 +35,7 @@ import { |
| 33 | nextGrokPlanModeActive, | 35 | nextGrokPlanModeActive, |
| 34 | selectGrokPermissionOptionId, | 36 | selectGrokPermissionOptionId, |
| 35 | } from "./GrokAdapter.ts"; | 37 | } from "./GrokAdapter.ts"; |
| 36 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; | 38 | |
| 37 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 38 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | 39 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 39 | 40 | ||
| 40 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | 41 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
| ... | @@ -232,6 +233,65 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { | ... | @@ -232,6 +233,65 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { |
| 232 | }), | 233 | }), |
| 233 | ); | 234 | ); |
| 234 | 235 | ||
| 236 | for (const taskType of ["monitor", "shell"] as const) { | ||
| 237 | it.effect(`emits the ${taskType} background lifecycle`, () => | ||
| 238 | Effect.gen(function* () { | ||
| 239 | const threadId = ThreadId.make(`grok-background-${taskType}`); | ||
| 240 | const wrapperPath = yield* Effect.promise(() => | ||
| 241 | makeMockGrokWrapper({ | ||
| 242 | [taskType === "monitor" | ||
| 243 | ? "T3_ACP_EMIT_GROK_MONITOR_POST_TURN_POLL" | ||
| 244 | : "T3_ACP_EMIT_GROK_BACKGROUND_TASK_STARTED"]: "1", | ||
| 245 | }), | ||
| 246 | ); | ||
| 247 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 248 | const events: ProviderRuntimeEvent[] = []; | ||
| 249 | const finished = yield* Deferred.make<void>(); | ||
| 250 | const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => | ||
| 251 | Effect.gen(function* () { | ||
| 252 | events.push(event); | ||
| 253 | if (event.type === (taskType === "monitor" ? "task.completed" : "turn.completed")) { | ||
| 254 | yield* Deferred.succeed(finished, undefined); | ||
| 255 | } | ||
| 256 | }), | ||
| 257 | ).pipe(Effect.forkChild); | ||
| 258 | yield* adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); | ||
| 259 | yield* adapter.sendTurn({ threadId, input: "watch the unit" }); | ||
| 260 | yield* Deferred.await(finished).pipe(Effect.timeout("3 seconds")); | ||
| 261 | |||
| 262 | const started = events.find((event) => event.type === "task.started"); | ||
| 263 | const taskId = | ||
| 264 | taskType === "monitor" | ||
| 265 | ? "01a05f41-5107-7550-821e-79e8d1cd7687" | ||
| 266 | : "call-fb9d0000-0000-0000-0000-000000000026"; | ||
| 267 | assert.equal(started?.payload.taskType, taskType); | ||
| 268 | assert.equal(started?.payload.taskId, taskId); | ||
| 269 | if (taskType === "monitor") { | ||
| 270 | const completed = events.find((event) => event.type === "task.completed"); | ||
| 271 | const turnEnd = events.findIndex((event) => event.type === "turn.completed"); | ||
| 272 | assert.equal(completed?.payload.status, "completed"); | ||
| 273 | assert.equal(completed?.payload.taskId, taskId); | ||
| 274 | assert.equal(completed?.turnId, undefined); | ||
| 275 | assert.isAtLeast(turnEnd, 0); | ||
| 276 | assert.isAbove( | ||
| 277 | events.findIndex((event) => event.type === "task.completed"), | ||
| 278 | turnEnd, | ||
| 279 | ); | ||
| 280 | assert.deepEqual( | ||
| 281 | events | ||
| 282 | .slice(turnEnd + 1) | ||
| 283 | .filter((event) => event.type === "item.updated" || event.type === "item.completed"), | ||
| 284 | [], | ||
| 285 | ); | ||
| 286 | } else { | ||
| 287 | assert.equal(started?.payload.description, "sleep 40; echo done-a"); | ||
| 288 | } | ||
| 289 | yield* Fiber.interrupt(eventsFiber); | ||
| 290 | yield* adapter.stopSession(threadId); | ||
| 291 | }).pipe(TestClock.withLive), | ||
| 292 | ); | ||
| 293 | } | ||
| 294 | |||
| 235 | it.effect("sends runtime context with the current model without changing saved prompts", () => | 295 | it.effect("sends runtime context with the current model without changing saved prompts", () => |
| 236 | Effect.gen(function* () { | 296 | Effect.gen(function* () { |
| 237 | const threadId = ThreadId.make("grok-runtime-context"); | 297 | const threadId = ThreadId.make("grok-runtime-context"); |
apps/server/src/provider/Layers/GrokAdapter.ts+25| ... | @@ -68,6 +68,10 @@ import { | ... | @@ -68,6 +68,10 @@ import { |
| 68 | normalizeGrokReasoningEffort, | 68 | normalizeGrokReasoningEffort, |
| 69 | resolveGrokAcpBaseModelId, | 69 | resolveGrokAcpBaseModelId, |
| 70 | } from "../acp/GrokAcpSupport.ts"; | 70 | } from "../acp/GrokAcpSupport.ts"; |
| 71 | import { | ||
| 72 | buildGrokBackgroundTaskEvents, | ||
| 73 | type GrokBackgroundTaskRecord, | ||
| 74 | } from "../acp/XAiBackgroundTasks.ts"; | ||
| 71 | import { | 75 | import { |
| 72 | extractGrokPlanMarkdownFromToolCallData, | 76 | extractGrokPlanMarkdownFromToolCallData, |
| 73 | extractXAiAskUserQuestions, | 77 | extractXAiAskUserQuestions, |
| ... | @@ -171,6 +175,8 @@ interface GrokSessionContext { | ... | @@ -171,6 +175,8 @@ interface GrokSessionContext { |
| 171 | currentModelId: string | undefined; | 175 | currentModelId: string | undefined; |
| 172 | currentReasoningEffort: string | undefined; | 176 | currentReasoningEffort: string | undefined; |
| 173 | stopped: boolean; | 177 | stopped: boolean; |
| 178 | /** Live monitor/shell identities and their originating turns. */ | ||
| 179 | readonly backgroundTasks: Map<string, GrokBackgroundTaskRecord>; | ||
| 174 | } | 180 | } |
| 175 | 181 | ||
| 176 | function settlePendingApprovalsAsCancelled( | 182 | function settlePendingApprovalsAsCancelled( |
| ... | @@ -1309,6 +1315,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -1309,6 +1315,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 1309 | ? normalizeGrokReasoningEffort(requestedStartReasoningEffort) | 1315 | ? normalizeGrokReasoningEffort(requestedStartReasoningEffort) |
| 1310 | : currentStartReasoningEffort, | 1316 | : currentStartReasoningEffort, |
| 1311 | stopped: false, | 1317 | stopped: false, |
| 1318 | backgroundTasks: new Map(), | ||
| 1312 | }; | 1319 | }; |
| 1313 | 1320 | ||
| 1314 | const nf = yield* Stream.runDrain( | 1321 | const nf = yield* Stream.runDrain( |
| ... | @@ -1331,6 +1338,24 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -1331,6 +1338,24 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 1331 | } | 1338 | } |
| 1332 | 1339 | ||
| 1333 | const notificationTurnId = resolveNotificationTurnId(ctx); | 1340 | const notificationTurnId = resolveNotificationTurnId(ctx); |
| 1341 | if (event._tag === "ToolCallUpdated" && !ctx.stopped) { | ||
| 1342 | for (const taskEvent of buildGrokBackgroundTaskEvents({ | ||
| 1343 | tasks: ctx.backgroundTasks, | ||
| 1344 | toolCallId: event.toolCall.toolCallId, | ||
| 1345 | rawInput: event.toolCall.data.rawInput, | ||
| 1346 | rawOutput: event.toolCall.data.rawOutput, | ||
| 1347 | toolCallStatus: event.toolCall.status, | ||
| 1348 | turnId: notificationTurnId, | ||
| 1349 | })) { | ||
| 1350 | yield* offerRuntimeEvent({ | ||
| 1351 | ...taskEvent, | ||
| 1352 | ...(yield* makeEventStamp()), | ||
| 1353 | provider: PROVIDER, | ||
| 1354 | threadId: ctx.threadId, | ||
| 1355 | }); | ||
| 1356 | } | ||
| 1357 | } | ||
| 1358 | |||
| 1334 | if ( | 1359 | if ( |
| 1335 | notificationTurnId === undefined || | 1360 | notificationTurnId === undefined || |
| 1336 | ctx.interruptedTurnIds.has(notificationTurnId) | 1361 | ctx.interruptedTurnIds.has(notificationTurnId) |
apps/server/src/provider/acp/XAiBackgroundTasks.test.ts created+202| ... | @@ -0,0 +1,202 @@ | ||
| 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 | }); | ||
apps/server/src/provider/acp/XAiBackgroundTasks.ts created+156| ... | @@ -0,0 +1,156 @@ | ||
| 1 | import { | ||
| 2 | RuntimeTaskId, | ||
| 3 | type ProviderRuntimeTaskStartedEvent, | ||
| 4 | type ProviderRuntimeTaskProgressEvent, | ||
| 5 | type ProviderRuntimeTaskCompletedEvent, | ||
| 6 | type TurnId, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | |||
| 9 | type TaskEvent = | ||
| 10 | | Pick<ProviderRuntimeTaskStartedEvent, "type" | "payload" | "turnId"> | ||
| 11 | | Pick<ProviderRuntimeTaskProgressEvent, "type" | "payload" | "turnId"> | ||
| 12 | | Pick<ProviderRuntimeTaskCompletedEvent, "type" | "payload" | "turnId">; | ||
| 13 | |||
| 14 | export interface GrokBackgroundTaskRecord { | ||
| 15 | readonly payload: { | ||
| 16 | readonly taskId: RuntimeTaskId; | ||
| 17 | readonly taskType: "monitor" | "shell"; | ||
| 18 | readonly description: string; | ||
| 19 | readonly title: string; | ||
| 20 | readonly toolUseId?: string; | ||
| 21 | }; | ||
| 22 | readonly turnId: TurnId | undefined; | ||
| 23 | } | ||
| 24 | |||
| 25 | function record(value: unknown): Record<string, unknown> { | ||
| 26 | return typeof value === "object" && value !== null && !Array.isArray(value) | ||
| 27 | ? (value as Record<string, unknown>) | ||
| 28 | : {}; | ||
| 29 | } | ||
| 30 | |||
| 31 | function text(value: unknown): string | undefined { | ||
| 32 | return typeof value === "string" ? value.trim() || undefined : undefined; | ||
| 33 | } | ||
| 34 | |||
| 35 | function lifecycle(status: unknown, exitCode: unknown) { | ||
| 36 | switch (text(status)?.toLowerCase()) { | ||
| 37 | case "pending": | ||
| 38 | case "running": | ||
| 39 | return "running"; | ||
| 40 | case "completed": | ||
| 41 | case "success": | ||
| 42 | case "succeeded": | ||
| 43 | return "completed"; | ||
| 44 | case "failed": | ||
| 45 | case "error": | ||
| 46 | return "failed"; | ||
| 47 | case "stopped": | ||
| 48 | case "killed": | ||
| 49 | case "cancelled": | ||
| 50 | return "stopped"; | ||
| 51 | default: | ||
| 52 | return typeof exitCode === "number" && Number.isFinite(exitCode) | ||
| 53 | ? exitCode === 0 | ||
| 54 | ? "completed" | ||
| 55 | : "failed" | ||
| 56 | : undefined; | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | /** Map Grok's discriminated tool results, including notifications after the turn ends. */ | ||
| 61 | export function buildGrokBackgroundTaskEvents(input: { | ||
| 62 | readonly tasks: Map<string, GrokBackgroundTaskRecord>; | ||
| 63 | readonly toolCallId: string; | ||
| 64 | readonly rawInput: unknown; | ||
| 65 | readonly rawOutput: unknown; | ||
| 66 | readonly toolCallStatus: string | undefined; | ||
| 67 | readonly turnId?: TurnId | undefined; | ||
| 68 | }): TaskEvent[] { | ||
| 69 | const { tasks, toolCallId, toolCallStatus, turnId } = input; | ||
| 70 | const output = record(input.rawOutput); | ||
| 71 | const events: TaskEvent[] = []; | ||
| 72 | if ( | ||
| 73 | toolCallStatus !== "completed" && | ||
| 74 | toolCallStatus !== "failed" && | ||
| 75 | output.type !== "BackgroundTaskStarted" | ||
| 76 | ) { | ||
| 77 | return events; | ||
| 78 | } | ||
| 79 | const attribution = (task: GrokBackgroundTaskRecord) => | ||
| 80 | task.turnId !== undefined && task.turnId === turnId ? { turnId } : {}; | ||
| 81 | const start = ( | ||
| 82 | id: string, | ||
| 83 | taskType: "monitor" | "shell", | ||
| 84 | description: string, | ||
| 85 | toolUseId?: string, | ||
| 86 | ) => { | ||
| 87 | const known = tasks.get(id); | ||
| 88 | if (known) return known; | ||
| 89 | const task: GrokBackgroundTaskRecord = { | ||
| 90 | payload: { | ||
| 91 | taskId: RuntimeTaskId.make(id), | ||
| 92 | taskType, | ||
| 93 | description, | ||
| 94 | title: description, | ||
| 95 | ...(toolUseId ? { toolUseId } : {}), | ||
| 96 | }, | ||
| 97 | // Polls can rediscover older tasks without establishing their originating turn. | ||
| 98 | turnId: toolUseId ? turnId : undefined, | ||
| 99 | }; | ||
| 100 | tasks.set(id, task); | ||
| 101 | events.push({ type: "task.started", payload: task.payload, ...attribution(task) }); | ||
| 102 | return task; | ||
| 103 | }; | ||
| 104 | const complete = ( | ||
| 105 | task: GrokBackgroundTaskRecord, | ||
| 106 | status: "completed" | "failed" | "stopped", | ||
| 107 | summary?: string, | ||
| 108 | ) => { | ||
| 109 | tasks.delete(task.payload.taskId); | ||
| 110 | events.push({ | ||
| 111 | type: "task.completed", | ||
| 112 | payload: { ...task.payload, status, ...(summary ? { summary } : {}) }, | ||
| 113 | ...attribution(task), | ||
| 114 | }); | ||
| 115 | }; | ||
| 116 | |||
| 117 | if (output.type === "Monitor" && toolCallStatus === "completed") { | ||
| 118 | const id = text(output.taskId); | ||
| 119 | if (id) start(id, "monitor", text(record(input.rawInput).description) ?? "Monitor", toolCallId); | ||
| 120 | } else if (output.type === "BackgroundTaskStarted") { | ||
| 121 | const id = text(output.task_id) ?? text(output.taskId); | ||
| 122 | const command = text(output.command); | ||
| 123 | if (id && command) start(id, "shell", command.split("\n")[0]!.slice(0, 200), toolCallId); | ||
| 124 | } else if (output.type === "TaskOutput" || output.type === "KillTask") { | ||
| 125 | const results = record(output.MultiResult).results; | ||
| 126 | for (const value of Array.isArray(results) ? results : [output.Result]) { | ||
| 127 | const result = record(value); | ||
| 128 | const id = text(result.task_id); | ||
| 129 | if (!id) continue; | ||
| 130 | if (output.type === "KillTask") { | ||
| 131 | const task = tasks.get(id); | ||
| 132 | if (task && toolCallStatus === "completed" && result.outcome === "killed") | ||
| 133 | complete(task, "stopped"); | ||
| 134 | continue; | ||
| 135 | } | ||
| 136 | const command = text(result.command); | ||
| 137 | const status = lifecycle(result.status, result.exit_code); | ||
| 138 | if (!command || !status || command.startsWith("[subagent:")) continue; | ||
| 139 | const task = start(id, /^\[monitor[:\]]/.test(command) ? "monitor" : "shell", command); | ||
| 140 | const summary = text(result.output) | ||
| 141 | ?.split("\n") | ||
| 142 | .find((line) => line.trim()) | ||
| 143 | ?.trim(); | ||
| 144 | if (status === "running") { | ||
| 145 | events.push({ | ||
| 146 | type: "task.progress", | ||
| 147 | payload: { ...task.payload, ...(summary ? { summary } : {}) }, | ||
| 148 | ...attribution(task), | ||
| 149 | }); | ||
| 150 | } else { | ||
| 151 | complete(task, status, summary); | ||
| 152 | } | ||
| 153 | } | ||
| 154 | } | ||
| 155 | return events; | ||
| 156 | } | ||