| 1 | import { it } from "@effect/vitest"; |
| 2 | import * as Deferred from "effect/Deferred"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; |
| 5 | import * as Schema from "effect/Schema"; |
| 6 | import { describe, expect } from "vite-plus/test"; |
| 7 | |
| 8 | import { |
| 9 | XAiExitPlanModeRequest, |
| 10 | XAI_EMPTY_PLAN_MARKDOWN, |
| 11 | isGrokPlanMarkdownPath, |
| 12 | extractXAiExitPlanMarkdown, |
| 13 | extractGrokPlanMarkdownFromToolCallData, |
| 14 | extractXAiAcpBackgroundToolMutation, |
| 15 | extractXAiAcpSubagentEndNotice, |
| 16 | extractXAiAcpSubagentUpdate, |
| 17 | extractXAiAskUserQuestions, |
| 18 | extractXAiBackgroundTaskCompletion, |
| 19 | extractXAiKilledBackgroundTasks, |
| 20 | extractXAiMonitorTaskId, |
| 21 | isGenericAcpToolTitle, |
| 22 | isXAiMonitorTool, |
| 23 | isXAiPersistentMonitor, |
| 24 | makeXAiAskUserQuestionResponse, |
| 25 | makeXAiPromptCompletionRuntime, |
| 26 | normalizeXAiAcpToolCallState, |
| 27 | registerXAiBackgroundTaskTracking, |
| 28 | registerXAiSubagentFinished, |
| 29 | resolveXAiAcpToolTitle, |
| 30 | xAiBackgroundTaskLifecycleMutation, |
| 31 | xAiPromptCompleteFromSessionUpdate, |
| 32 | xAiSubagentFinishedNotice, |
| 33 | XAiAskUserQuestionRequest, |
| 34 | } from "./xaiAcpExtension.ts"; |
| 35 | import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; |
| 36 | import { |
| 37 | type AcpToolCallState, |
| 38 | mergeToolCallState, |
| 39 | parseSessionUpdateEvent, |
| 40 | } from "@t3tools/provider-acp/server/runtimeModel"; |
| 41 | |
| 42 | const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); |
| 43 | |
| 44 | describe("xAiPromptCompleteFromSessionUpdate", () => { |
| 45 | it("maps live turn_completed snake_case payloads", () => { |
| 46 | expect( |
| 47 | xAiPromptCompleteFromSessionUpdate({ |
| 48 | sessionId: "019f4428-4bf1-7e52-b7c6-c29b506543b1", |
| 49 | update: { |
| 50 | sessionUpdate: "turn_completed", |
| 51 | prompt_id: "t3-xai-prompt-1", |
| 52 | stop_reason: "end_turn", |
| 53 | }, |
| 54 | }), |
| 55 | ).toEqual({ |
| 56 | sessionId: "019f4428-4bf1-7e52-b7c6-c29b506543b1", |
| 57 | promptId: "t3-xai-prompt-1", |
| 58 | stopReason: "end_turn", |
| 59 | }); |
| 60 | }); |
| 61 | |
| 62 | it("ignores non-turn updates and task-completed prompt ids", () => { |
| 63 | expect( |
| 64 | xAiPromptCompleteFromSessionUpdate({ |
| 65 | sessionId: "root", |
| 66 | update: { sessionUpdate: "hook_execution", prompt_id: "t3-xai-prompt-1" }, |
| 67 | }), |
| 68 | ).toBeNull(); |
| 69 | expect( |
| 70 | xAiPromptCompleteFromSessionUpdate({ |
| 71 | sessionId: "root", |
| 72 | update: { |
| 73 | sessionUpdate: "turn_completed", |
| 74 | prompt_id: "task-completed-call-abc", |
| 75 | stop_reason: "end_turn", |
| 76 | }, |
| 77 | }), |
| 78 | ).toBeNull(); |
| 79 | expect( |
| 80 | xAiPromptCompleteFromSessionUpdate({ |
| 81 | sessionId: "root", |
| 82 | update: { sessionUpdate: "turn_completed", stop_reason: "end_turn" }, |
| 83 | }), |
| 84 | ).toBeNull(); |
| 85 | }); |
| 86 | }); |
| 87 | |
| 88 | // Runs a recorded Grok tool_call_update through the adapter's parse path. |
| 89 | function parseRecordedToolCallUpdate(update: Record<string, unknown>): AcpToolCallState { |
| 90 | const [event] = parseSessionUpdateEvent({ |
| 91 | sessionId: "01a0d660-c1bb-7842-91a3-02d91dc8b0d2", |
| 92 | update, |
| 93 | } as Parameters<typeof parseSessionUpdateEvent>[0]).events; |
| 94 | if (event?._tag !== "ToolCallUpdated") throw new Error("expected a tool call update"); |
| 95 | return event.toolCall; |
| 96 | } |
| 97 | |
| 98 | describe("XAiAcpExtension", () => { |
| 99 | it("recognizes Grok Task starts as native subagents", () => { |
| 100 | expect( |
| 101 | extractXAiAcpSubagentUpdate({ |
| 102 | toolCallId: "task-1", |
| 103 | title: "Task", |
| 104 | status: "inProgress", |
| 105 | data: { |
| 106 | rawInput: { |
| 107 | description: "Explore server architecture", |
| 108 | prompt: "Audit apps/server.", |
| 109 | subagent_type: "generalPurpose", |
| 110 | model: "composer-2.5-fast", |
| 111 | }, |
| 112 | }, |
| 113 | }), |
| 114 | ).toEqual({ |
| 115 | nativeTaskId: "task-1", |
| 116 | prompt: "Audit apps/server.", |
| 117 | title: "Explore server architecture", |
| 118 | model: "composer-2.5-fast", |
| 119 | status: "running", |
| 120 | childSessionId: null, |
| 121 | result: null, |
| 122 | suppressNormalTool: true, |
| 123 | }); |
| 124 | }); |
| 125 | |
| 126 | it("extracts Grok child session lineage from completed Task output", () => { |
| 127 | expect( |
| 128 | extractXAiAcpSubagentUpdate({ |
| 129 | toolCallId: "task-1", |
| 130 | title: "Task", |
| 131 | status: "completed", |
| 132 | data: { |
| 133 | rawInput: { |
| 134 | description: "Explore server architecture", |
| 135 | prompt: "Audit apps/server.", |
| 136 | subagent_type: "generalPurpose", |
| 137 | }, |
| 138 | rawOutput: { |
| 139 | type: "Text", |
| 140 | text: [ |
| 141 | "Server audit complete.", |
| 142 | "", |
| 143 | "Agent ID: 019f0220-e192-7c41-9e9d-b406bc3459c8 (resume supported)", |
| 144 | ].join("\n"), |
| 145 | }, |
| 146 | }, |
| 147 | }), |
| 148 | ).toEqual({ |
| 149 | nativeTaskId: "task-1", |
| 150 | prompt: "Audit apps/server.", |
| 151 | title: "Explore server architecture", |
| 152 | model: null, |
| 153 | status: "completed", |
| 154 | childSessionId: "019f0220-e192-7c41-9e9d-b406bc3459c8", |
| 155 | result: "Server audit complete.", |
| 156 | suppressNormalTool: true, |
| 157 | }); |
| 158 | }); |
| 159 | |
| 160 | it("keeps async spawn_subagent ACKs running and binds subagent_id", () => { |
| 161 | expect( |
| 162 | extractXAiAcpSubagentUpdate({ |
| 163 | toolCallId: "call-spawn-1", |
| 164 | title: "spawn_subagent", |
| 165 | status: "completed", |
| 166 | data: { |
| 167 | rawInput: { |
| 168 | description: "Demo subagent wait and ls", |
| 169 | prompt: "Wait then ls.", |
| 170 | subagent_type: "general-purpose", |
| 171 | }, |
| 172 | rawOutput: { |
| 173 | type: "Text", |
| 174 | text: [ |
| 175 | "Subagent started in background.", |
| 176 | "subagent_id: 019f44a6-4820-7402-925d-bc862ee711dd", |
| 177 | "type: general-purpose", |
| 178 | "description: Demo subagent wait and ls", |
| 179 | "", |
| 180 | 'Use get_command_or_subagent_output with task_ids=["019f44a6-4820-7402-925d-bc862ee711dd"] and timeout_ms to wait for results.', |
| 181 | ].join("\n"), |
| 182 | }, |
| 183 | }, |
| 184 | }), |
| 185 | ).toEqual({ |
| 186 | nativeTaskId: "call-spawn-1", |
| 187 | prompt: "Wait then ls.", |
| 188 | title: "Demo subagent wait and ls", |
| 189 | model: null, |
| 190 | status: "running", |
| 191 | childSessionId: "019f44a6-4820-7402-925d-bc862ee711dd", |
| 192 | result: null, |
| 193 | suppressNormalTool: true, |
| 194 | }); |
| 195 | }); |
| 196 | |
| 197 | it("hydrates subagent completion from get_command_or_subagent_output", () => { |
| 198 | expect( |
| 199 | extractXAiAcpSubagentUpdate({ |
| 200 | toolCallId: "call-get-1", |
| 201 | title: "get_command_or_subagent_output", |
| 202 | status: "completed", |
| 203 | data: { |
| 204 | rawInput: { |
| 205 | task_ids: ["019f44a6-4820-7402-925d-bc862ee711dd"], |
| 206 | timeout_ms: 30000, |
| 207 | }, |
| 208 | rawOutput: { |
| 209 | type: "Text", |
| 210 | text: [ |
| 211 | "=== Task 019f44a6-4820-7402-925d-bc862ee711dd ===", |
| 212 | "Status: completed", |
| 213 | "Exit Code: 0", |
| 214 | "", |
| 215 | "=== Output ===", |
| 216 | "SUBAGENT_MARKER: 35 entries", |
| 217 | ].join("\n"), |
| 218 | }, |
| 219 | }, |
| 220 | }), |
| 221 | ).toEqual({ |
| 222 | nativeTaskId: "call-get-1", |
| 223 | prompt: "", |
| 224 | title: null, |
| 225 | model: null, |
| 226 | status: "completed", |
| 227 | childSessionId: "019f44a6-4820-7402-925d-bc862ee711dd", |
| 228 | result: "SUBAGENT_MARKER: 35 entries", |
| 229 | suppressNormalTool: true, |
| 230 | }); |
| 231 | }); |
| 232 | |
| 233 | it("attributes multi-task get_output envelopes to the result task_id", () => { |
| 234 | expect( |
| 235 | extractXAiAcpSubagentUpdate({ |
| 236 | toolCallId: "call-get-multi", |
| 237 | title: "get_command_or_subagent_output", |
| 238 | status: "completed", |
| 239 | data: { |
| 240 | rawInput: { |
| 241 | task_ids: [ |
| 242 | "019f44a6-4820-7402-925d-bc862ee711dd", |
| 243 | "019f44b0-1b73-7f32-bb1b-1ff696f536e3", |
| 244 | ], |
| 245 | timeout_ms: 30000, |
| 246 | }, |
| 247 | rawOutput: { |
| 248 | type: "TaskOutput", |
| 249 | Result: { |
| 250 | task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", |
| 251 | status: "completed", |
| 252 | exit_code: 0, |
| 253 | output: "SECOND_SUB_DONE\n", |
| 254 | }, |
| 255 | }, |
| 256 | }, |
| 257 | }), |
| 258 | ).toMatchObject({ |
| 259 | childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", |
| 260 | status: "completed", |
| 261 | result: "SECOND_SUB_DONE", |
| 262 | }); |
| 263 | }); |
| 264 | |
| 265 | it("does not treat a subagent_id mentioned in result text as the completed identity", () => { |
| 266 | const requestedTaskId = "019f44a6-4820-7402-925d-bc862ee711dd"; |
| 267 | const mentionedTaskId = "019f44b0-1b73-7f32-bb1b-1ff696f536e3"; |
| 268 | const toolCall = { |
| 269 | toolCallId: "call-get-mentioned-id", |
| 270 | title: "get_command_or_subagent_output", |
| 271 | status: "completed" as const, |
| 272 | data: { |
| 273 | rawInput: { |
| 274 | variant: "TaskOutput", |
| 275 | task_ids: [requestedTaskId], |
| 276 | }, |
| 277 | rawOutput: { |
| 278 | type: "TaskOutput", |
| 279 | Result: { |
| 280 | status: "completed", |
| 281 | exit_code: 0, |
| 282 | output: `Audited subagent_id: ${mentionedTaskId}; no issues found.`, |
| 283 | }, |
| 284 | }, |
| 285 | }, |
| 286 | }; |
| 287 | |
| 288 | expect(extractXAiAcpSubagentUpdate(toolCall)).toMatchObject({ |
| 289 | childSessionId: requestedTaskId, |
| 290 | status: "completed", |
| 291 | }); |
| 292 | expect(extractXAiBackgroundTaskCompletion(toolCall)).toEqual([ |
| 293 | { |
| 294 | taskId: requestedTaskId, |
| 295 | status: "completed", |
| 296 | appendOutput: `Audited subagent_id: ${mentionedTaskId}; no issues found.`, |
| 297 | }, |
| 298 | ]); |
| 299 | }); |
| 300 | |
| 301 | it("hydrates structured ACP TaskOutput tool envelopes", () => { |
| 302 | expect( |
| 303 | extractXAiAcpSubagentUpdate({ |
| 304 | toolCallId: "call-get-2", |
| 305 | title: "[subagent:general-purpose] Sleep then return SUBAGENT_DONE (019f44b0)", |
| 306 | status: "completed", |
| 307 | data: { |
| 308 | rawInput: { |
| 309 | variant: "TaskOutput", |
| 310 | task_ids: ["019f44b0-1b73-7f32-bb1b-1ff696f536e3"], |
| 311 | timeout_ms: 20000, |
| 312 | }, |
| 313 | rawOutput: { |
| 314 | type: "TaskOutput", |
| 315 | Result: { |
| 316 | task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", |
| 317 | status: "completed", |
| 318 | exit_code: 0, |
| 319 | output: |
| 320 | "SUBAGENT_DONE\n\n<subagent_meta>id=019f44b0-1b73-7f32-bb1b-1ff696f536e3</subagent_meta>\n", |
| 321 | }, |
| 322 | }, |
| 323 | }, |
| 324 | }), |
| 325 | ).toEqual({ |
| 326 | nativeTaskId: "call-get-2", |
| 327 | prompt: "", |
| 328 | title: null, |
| 329 | model: null, |
| 330 | status: "completed", |
| 331 | childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", |
| 332 | result: "SUBAGENT_DONE", |
| 333 | suppressNormalTool: true, |
| 334 | }); |
| 335 | }); |
| 336 | |
| 337 | it("does not leak raw machine tags when get_output is only meta/result blocks", () => { |
| 338 | const metaOnlyToolCall = { |
| 339 | toolCallId: "call-get-meta-only", |
| 340 | title: "get_command_or_subagent_output", |
| 341 | status: "completed" as const, |
| 342 | data: { |
| 343 | rawInput: { |
| 344 | variant: "TaskOutput", |
| 345 | task_ids: ["019f44b0-1b73-7f32-bb1b-1ff696f536e3"], |
| 346 | timeout_ms: 20000, |
| 347 | }, |
| 348 | rawOutput: { |
| 349 | type: "TaskOutput", |
| 350 | Result: { |
| 351 | task_id: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", |
| 352 | status: "completed", |
| 353 | exit_code: 0, |
| 354 | output: |
| 355 | "<subagent_meta>id=019f44b0-1b73-7f32-bb1b-1ff696f536e3</subagent_meta>\n<subagent_result>done</subagent_result>\n", |
| 356 | }, |
| 357 | }, |
| 358 | }, |
| 359 | }; |
| 360 | expect(extractXAiAcpSubagentUpdate(metaOnlyToolCall)).toEqual({ |
| 361 | nativeTaskId: "call-get-meta-only", |
| 362 | prompt: "", |
| 363 | title: null, |
| 364 | model: null, |
| 365 | status: "completed", |
| 366 | childSessionId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", |
| 367 | result: null, |
| 368 | suppressNormalTool: true, |
| 369 | }); |
| 370 | expect(extractXAiBackgroundTaskCompletion(metaOnlyToolCall)).toEqual([ |
| 371 | { |
| 372 | taskId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", |
| 373 | status: "completed", |
| 374 | appendOutput: "", |
| 375 | }, |
| 376 | ]); |
| 377 | }); |
| 378 | |
| 379 | it("keeps monitor start ACKs running and extracts task ids", () => { |
| 380 | const toolCall = { |
| 381 | toolCallId: "call-mon-1", |
| 382 | title: "monitor", |
| 383 | status: "completed" as const, |
| 384 | data: { |
| 385 | rawInput: { |
| 386 | command: "echo mon_line_1", |
| 387 | description: "Demo monitor", |
| 388 | }, |
| 389 | rawOutput: { |
| 390 | type: "Text", |
| 391 | text: "Monitor started (task 019f44a5-87d1-7640-8e35-6a4667ffc873, timeout 36000000ms).\nYou will be notified on each event.", |
| 392 | }, |
| 393 | }, |
| 394 | }; |
| 395 | expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress"); |
| 396 | expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44a5-87d1-7640-8e35-6a4667ffc873"); |
| 397 | }); |
| 398 | |
| 399 | it("tracks text Monitor ACKs with generic titles and description-only input", () => { |
| 400 | const toolCall = { |
| 401 | toolCallId: "call-mon-generic-text", |
| 402 | title: "Tool", |
| 403 | status: "completed" as const, |
| 404 | data: { |
| 405 | rawInput: { |
| 406 | command: "echo mon_line_1", |
| 407 | description: "Demo monitor without variant", |
| 408 | }, |
| 409 | rawOutput: { |
| 410 | type: "Text", |
| 411 | text: "Monitor started (task 019f44a5-87d1-7640-8e35-6a4667ffc873, timeout 36000000ms).\nYou will be notified on each event.", |
| 412 | }, |
| 413 | }, |
| 414 | }; |
| 415 | |
| 416 | const normalized = normalizeXAiAcpToolCallState(toolCall); |
| 417 | expect(isXAiMonitorTool(normalized)).toBe(true); |
| 418 | expect(normalized.status).toBe("inProgress"); |
| 419 | expect(extractXAiMonitorTaskId(normalized)).toBe("019f44a5-87d1-7640-8e35-6a4667ffc873"); |
| 420 | }); |
| 421 | |
| 422 | it("keeps Monitor ticks running while Grok reports them in progress", () => { |
| 423 | // Recorded from Grok 1.0.41: every tick is an in_progress update whose |
| 424 | // Bash-shaped rawOutput already carries exit_code 0. The monitor ends only |
| 425 | // through `_x.ai/task_completed`, never through a completed status. |
| 426 | const command = "for i in 1 2 3; do sleep 8; echo tick $i; done"; |
| 427 | const started = parseRecordedToolCallUpdate({ |
| 428 | sessionUpdate: "tool_call_update", |
| 429 | toolCallId: "call-6b025a4e-621c-4526-a579-ba86ada2669c-0", |
| 430 | kind: "other", |
| 431 | title: "Start monitor: Watch three ticks eight seconds apart", |
| 432 | locations: [], |
| 433 | rawInput: { |
| 434 | variant: "Monitor", |
| 435 | command, |
| 436 | description: "Watch three ticks eight seconds apart", |
| 437 | timeout_ms: 36000000, |
| 438 | persistent: false, |
| 439 | }, |
| 440 | }); |
| 441 | const tick = parseRecordedToolCallUpdate({ |
| 442 | sessionUpdate: "tool_call_update", |
| 443 | toolCallId: "call-6b025a4e-621c-4526-a579-ba86ada2669c-0", |
| 444 | status: "in_progress", |
| 445 | content: [{ type: "content", content: { type: "text", text: "tick 1\n" } }], |
| 446 | rawOutput: { |
| 447 | type: "Bash", |
| 448 | output: [116, 105, 99, 107, 32, 49, 10], |
| 449 | output_for_prompt: "tick 1\n", |
| 450 | exit_code: 0, |
| 451 | command, |
| 452 | truncated: false, |
| 453 | signal: null, |
| 454 | timed_out: false, |
| 455 | description: null, |
| 456 | current_dir: "<workspace>", |
| 457 | output_file: "", |
| 458 | total_bytes: 7, |
| 459 | }, |
| 460 | }); |
| 461 | const toolCall = normalizeXAiAcpToolCallState(mergeToolCallState(started, tick)); |
| 462 | expect(isXAiMonitorTool(toolCall)).toBe(true); |
| 463 | expect(toolCall.status).toBe("inProgress"); |
| 464 | }); |
| 465 | |
| 466 | it("finishes monitors from the recorded task_completed snapshot", () => { |
| 467 | // Fields from the grok_monitor recording (Grok 1.0.41). |
| 468 | const completed = { |
| 469 | sessionId: "01a0d660-c1bb-7842-91a3-02d91dc8b0d2", |
| 470 | update: { |
| 471 | sessionUpdate: "task_completed", |
| 472 | task_snapshot: { |
| 473 | task_id: "01a0d660-f98d-7ef3-a291-97af1c2b6455", |
| 474 | command: "for i in 1 2 3; do sleep 8; echo tick $i; done", |
| 475 | display_command: "[monitor] Watch three tick echoes", |
| 476 | output: "tick 1\ntick 2\ntick 3\n", |
| 477 | exit_code: 0, |
| 478 | kind: "monitor", |
| 479 | description: "Watch three tick echoes", |
| 480 | }, |
| 481 | }, |
| 482 | }; |
| 483 | expect(xAiBackgroundTaskLifecycleMutation(completed, "completed")).toEqual({ |
| 484 | sessionId: "01a0d660-c1bb-7842-91a3-02d91dc8b0d2", |
| 485 | taskId: "01a0d660-f98d-7ef3-a291-97af1c2b6455", |
| 486 | status: "completed", |
| 487 | output: "tick 1\ntick 2\ntick 3\n", |
| 488 | report: { kind: "monitor", label: "Watch three tick echoes" }, |
| 489 | }); |
| 490 | expect( |
| 491 | xAiBackgroundTaskLifecycleMutation( |
| 492 | { |
| 493 | ...completed, |
| 494 | update: { ...completed.update, task_snapshot: { task_id: "t", exit_code: 2 } }, |
| 495 | }, |
| 496 | "completed", |
| 497 | )?.status, |
| 498 | ).toBe("failed"); |
| 499 | }); |
| 500 | |
| 501 | it("detects Monitor start ACKs from structured rawOutput when title is generic", () => { |
| 502 | const toolCall = { |
| 503 | toolCallId: "call-mon-generic-title", |
| 504 | title: "Tool", |
| 505 | status: "completed" as const, |
| 506 | data: { |
| 507 | rawInput: { |
| 508 | command: "echo mon_line", |
| 509 | description: "Demo monitor without variant", |
| 510 | }, |
| 511 | rawOutput: { |
| 512 | type: "Monitor", |
| 513 | taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", |
| 514 | timeoutMs: 36000000, |
| 515 | persistent: false, |
| 516 | }, |
| 517 | }, |
| 518 | }; |
| 519 | expect(isXAiMonitorTool(toolCall)).toBe(true); |
| 520 | expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress"); |
| 521 | expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44a5-87d1-7640-8e35-6a4667ffc873"); |
| 522 | }); |
| 523 | |
| 524 | it("completes status-less Bash results despite generic titles", () => { |
| 525 | // A Bash result that carries no status (empty rawInput, generic title) is |
| 526 | // terminalized from its exit_code. An explicit in_progress status wins: |
| 527 | // that is how Grok streams a still-running monitor. |
| 528 | const toolCall = { |
| 529 | toolCallId: "call-wake-1", |
| 530 | title: "Tool", |
| 531 | data: { |
| 532 | rawInput: {}, |
| 533 | rawOutput: { |
| 534 | type: "Bash", |
| 535 | output: Array.from(new TextEncoder().encode("STREAM_1\nSTREAM_DONE\n")), |
| 536 | output_for_prompt: "STREAM_1\nSTREAM_DONE\n", |
| 537 | exit_code: 0, |
| 538 | command: "for i in 1 2; do echo STREAM_$i; done; echo STREAM_DONE", |
| 539 | }, |
| 540 | }, |
| 541 | }; |
| 542 | expect(isXAiMonitorTool(toolCall)).toBe(false); |
| 543 | const normalized = normalizeXAiAcpToolCallState(toolCall); |
| 544 | expect(normalized.status).toBe("completed"); |
| 545 | expect(normalized.title).toBe("for i in 1 2; do echo STREAM_$i; done; echo STREAM_DONE"); |
| 546 | expect( |
| 547 | normalizeXAiAcpToolCallState({ |
| 548 | ...toolCall, |
| 549 | data: { |
| 550 | ...toolCall.data, |
| 551 | rawOutput: { ...toolCall.data.rawOutput, exit_code: 2 }, |
| 552 | }, |
| 553 | }).status, |
| 554 | ).toBe("failed"); |
| 555 | }); |
| 556 | |
| 557 | it("keeps empty successful Bash updates running until native output or turn settlement", () => { |
| 558 | const normalized = normalizeXAiAcpToolCallState({ |
| 559 | toolCallId: "call-bash-permission-ack", |
| 560 | title: "Ran command", |
| 561 | status: "completed", |
| 562 | data: { |
| 563 | rawInput: { command: "sleep 40" }, |
| 564 | rawOutput: { type: "Bash", exit_code: 0 }, |
| 565 | }, |
| 566 | }); |
| 567 | expect(normalized.status).toBe("inProgress"); |
| 568 | }); |
| 569 | |
| 570 | it("keeps structured Monitor start ACKs running and extracts taskId", () => { |
| 571 | const toolCall = { |
| 572 | toolCallId: "call-mon-3", |
| 573 | title: "Start monitor: Wait 30s then list directory", |
| 574 | status: "completed" as const, |
| 575 | data: { |
| 576 | rawInput: { |
| 577 | variant: "Monitor", |
| 578 | command: "sleep 30 && ls", |
| 579 | description: "Wait 30s then list directory", |
| 580 | }, |
| 581 | rawOutput: { |
| 582 | type: "Monitor", |
| 583 | taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", |
| 584 | timeoutMs: 36000000, |
| 585 | persistent: false, |
| 586 | }, |
| 587 | }, |
| 588 | }; |
| 589 | expect(normalizeXAiAcpToolCallState(toolCall).status).toBe("inProgress"); |
| 590 | expect(extractXAiMonitorTaskId(toolCall)).toBe("019f44b8-8e98-7c80-a40e-df1e26a5f9e3"); |
| 591 | }); |
| 592 | |
| 593 | it("replaces generic ACP titles with description / Monitor labels", () => { |
| 594 | expect(isGenericAcpToolTitle("Tool")).toBe(true); |
| 595 | expect(isGenericAcpToolTitle("Read package.json")).toBe(false); |
| 596 | expect( |
| 597 | resolveXAiAcpToolTitle({ |
| 598 | toolCallId: "call-mon-title", |
| 599 | title: "Tool", |
| 600 | status: "completed", |
| 601 | data: { |
| 602 | rawInput: { |
| 603 | variant: "Monitor", |
| 604 | command: "sleep 30 && ls", |
| 605 | description: "Wait 30s then list directory", |
| 606 | }, |
| 607 | }, |
| 608 | }), |
| 609 | ).toBe("Monitor: Wait 30s then list directory"); |
| 610 | const normalized = normalizeXAiAcpToolCallState({ |
| 611 | toolCallId: "call-mon-title", |
| 612 | title: "Tool", |
| 613 | status: "completed", |
| 614 | data: { |
| 615 | rawInput: { |
| 616 | variant: "Monitor", |
| 617 | command: "sleep 30 && ls", |
| 618 | description: "Wait 30s then list directory", |
| 619 | }, |
| 620 | rawOutput: { |
| 621 | type: "Monitor", |
| 622 | taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", |
| 623 | timeoutMs: 36000000, |
| 624 | persistent: false, |
| 625 | }, |
| 626 | }, |
| 627 | }); |
| 628 | expect(normalized.title).toBe("Monitor: Wait 30s then list directory"); |
| 629 | expect(normalized.status).toBe("inProgress"); |
| 630 | // Non-generic titles from the CLI are preserved. |
| 631 | expect( |
| 632 | resolveXAiAcpToolTitle({ |
| 633 | toolCallId: "call-read", |
| 634 | title: "Read package.json", |
| 635 | status: "inProgress", |
| 636 | data: { rawInput: { path: "package.json" } }, |
| 637 | }), |
| 638 | ).toBe("Read package.json"); |
| 639 | }); |
| 640 | |
| 641 | it("hydrates monitor completion from TaskOutput get_command envelopes", () => { |
| 642 | expect( |
| 643 | extractXAiBackgroundTaskCompletion({ |
| 644 | toolCallId: "call-get-mon", |
| 645 | title: "Wait 30s then list directory", |
| 646 | status: "completed", |
| 647 | data: { |
| 648 | rawInput: { |
| 649 | variant: "TaskOutput", |
| 650 | task_ids: ["019f44b8-8e98-7c80-a40e-df1e26a5f9e3"], |
| 651 | }, |
| 652 | rawOutput: { |
| 653 | type: "TaskOutput", |
| 654 | Result: { |
| 655 | task_id: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", |
| 656 | command: "[monitor] Wait 30s then list directory", |
| 657 | status: "completed", |
| 658 | exit_code: 0, |
| 659 | output: "agents\nAGENTS.md\nnotes\n", |
| 660 | }, |
| 661 | }, |
| 662 | }, |
| 663 | }), |
| 664 | ).toEqual([ |
| 665 | { |
| 666 | taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", |
| 667 | status: "completed", |
| 668 | appendOutput: "agents\nAGENTS.md\nnotes", |
| 669 | }, |
| 670 | ]); |
| 671 | }); |
| 672 | |
| 673 | it("hydrates monitor completion from a standalone TaskOutput frame without rawInput", () => { |
| 674 | // Post-settle shape observed live (2026-07-12): the final |
| 675 | // get_command_or_subagent_output update carries only rawOutput; the frame |
| 676 | // is parsed in isolation (no in-turn merge), and this completion is the |
| 677 | // ONLY end signal when the agent consumed the output itself (no "Monitor |
| 678 | // ended" reminder follows). |
| 679 | expect( |
| 680 | extractXAiBackgroundTaskCompletion({ |
| 681 | toolCallId: "call-c29e64dd-ce5d-4eac-a6ca-c20513fefac3-1", |
| 682 | title: "[monitor] Stream numbered markers every 3s (019f54a0)", |
| 683 | status: "completed", |
| 684 | data: { |
| 685 | rawOutput: { |
| 686 | type: "TaskOutput", |
| 687 | Result: { |
| 688 | task_id: "019f54a0-06a8-77f2-8214-e24937cad564", |
| 689 | command: "[monitor] Stream numbered markers every 3s", |
| 690 | status: "completed", |
| 691 | exit_code: 0, |
| 692 | output: "STREAM_1\nSTREAM_DONE\n", |
| 693 | }, |
| 694 | }, |
| 695 | }, |
| 696 | }), |
| 697 | ).toMatchObject([ |
| 698 | { |
| 699 | taskId: "019f54a0-06a8-77f2-8214-e24937cad564", |
| 700 | status: "completed", |
| 701 | }, |
| 702 | ]); |
| 703 | }); |
| 704 | |
| 705 | it("hydrates every requested monitor when TaskOutput has no result task_id", () => { |
| 706 | expect( |
| 707 | extractXAiBackgroundTaskCompletion({ |
| 708 | toolCallId: "call-get-multi-mon", |
| 709 | title: "get_command_or_subagent_output", |
| 710 | status: "completed", |
| 711 | data: { |
| 712 | rawInput: { |
| 713 | variant: "TaskOutput", |
| 714 | task_ids: [ |
| 715 | "019f44a6-4820-7402-925d-bc862ee711dd", |
| 716 | "019f44b0-1b73-7f32-bb1b-1ff696f536e3", |
| 717 | ], |
| 718 | }, |
| 719 | rawOutput: { |
| 720 | type: "TaskOutput", |
| 721 | Result: { |
| 722 | status: "completed", |
| 723 | exit_code: 0, |
| 724 | output: "BOTH_DONE\n", |
| 725 | }, |
| 726 | }, |
| 727 | }, |
| 728 | }), |
| 729 | ).toEqual([ |
| 730 | { |
| 731 | taskId: "019f44a6-4820-7402-925d-bc862ee711dd", |
| 732 | status: "completed", |
| 733 | appendOutput: "BOTH_DONE", |
| 734 | }, |
| 735 | { |
| 736 | taskId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", |
| 737 | status: "completed", |
| 738 | appendOutput: "BOTH_DONE", |
| 739 | }, |
| 740 | ]); |
| 741 | }); |
| 742 | |
| 743 | it("keeps every requested monitor running when a completed poll has no terminal task status", () => { |
| 744 | expect( |
| 745 | extractXAiBackgroundTaskCompletion({ |
| 746 | toolCallId: "call-get-running-monitors", |
| 747 | title: "get_command_or_subagent_output", |
| 748 | status: "completed", |
| 749 | data: { |
| 750 | rawInput: { |
| 751 | variant: "TaskOutput", |
| 752 | task_ids: [ |
| 753 | "019f44a6-4820-7402-925d-bc862ee711dd", |
| 754 | "019f44b0-1b73-7f32-bb1b-1ff696f536e3", |
| 755 | ], |
| 756 | }, |
| 757 | rawOutput: { |
| 758 | type: "TaskOutput", |
| 759 | Result: { |
| 760 | output: "No task has reached a terminal state.", |
| 761 | }, |
| 762 | }, |
| 763 | }, |
| 764 | }), |
| 765 | ).toEqual([ |
| 766 | { |
| 767 | taskId: "019f44a6-4820-7402-925d-bc862ee711dd", |
| 768 | status: "running", |
| 769 | appendOutput: "No task has reached a terminal state.", |
| 770 | }, |
| 771 | { |
| 772 | taskId: "019f44b0-1b73-7f32-bb1b-1ff696f536e3", |
| 773 | status: "running", |
| 774 | appendOutput: "No task has reached a terminal state.", |
| 775 | }, |
| 776 | ]); |
| 777 | }); |
| 778 | |
| 779 | it("treats Exit Code: -1 text envelopes as failed", () => { |
| 780 | expect( |
| 781 | extractXAiBackgroundTaskCompletion({ |
| 782 | toolCallId: "call-get-neg", |
| 783 | title: "get_command_or_subagent_output", |
| 784 | status: "completed", |
| 785 | data: { |
| 786 | rawInput: { |
| 787 | variant: "TaskOutput", |
| 788 | task_ids: ["019f44b8-8e98-7c80-a40e-df1e26a5f9e3"], |
| 789 | }, |
| 790 | rawOutput: { |
| 791 | type: "Text", |
| 792 | text: ["=== Task 019f44b8-8e98-7c80-a40e-df1e26a5f9e3 ===", "Exit Code: -1"].join("\n"), |
| 793 | }, |
| 794 | }, |
| 795 | }), |
| 796 | ).toMatchObject([ |
| 797 | { |
| 798 | taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", |
| 799 | status: "failed", |
| 800 | }, |
| 801 | ]); |
| 802 | }); |
| 803 | |
| 804 | it("tombstones confirmed kills and ignores failed kill calls", () => { |
| 805 | expect( |
| 806 | extractXAiKilledBackgroundTasks({ |
| 807 | toolCallId: "call-kill-1", |
| 808 | title: "kill_command_or_subagent", |
| 809 | status: "completed", |
| 810 | data: { |
| 811 | rawInput: { |
| 812 | variant: "Kill", |
| 813 | task_ids: ["call-bg-1", "call-bg-2", "call-bg-1"], |
| 814 | }, |
| 815 | }, |
| 816 | }), |
| 817 | ).toEqual([ |
| 818 | { taskId: "call-bg-1", status: "completed", appendOutput: "" }, |
| 819 | { taskId: "call-bg-2", status: "completed", appendOutput: "" }, |
| 820 | ]); |
| 821 | // Singular task_id shape, matched by variant when the title is generic. |
| 822 | expect( |
| 823 | extractXAiKilledBackgroundTasks({ |
| 824 | toolCallId: "call-kill-2", |
| 825 | title: "Tool", |
| 826 | status: "failed", |
| 827 | data: { rawInput: { variant: "kill", task_id: "call-bg-3" } }, |
| 828 | }), |
| 829 | ).toEqual([]); |
| 830 | // In-flight kill calls and unrelated tools contribute nothing. |
| 831 | expect( |
| 832 | extractXAiKilledBackgroundTasks({ |
| 833 | toolCallId: "call-kill-3", |
| 834 | title: "kill_command_or_subagent", |
| 835 | status: "inProgress", |
| 836 | data: { rawInput: { task_ids: ["call-bg-4"] } }, |
| 837 | }), |
| 838 | ).toEqual([]); |
| 839 | expect( |
| 840 | extractXAiKilledBackgroundTasks({ |
| 841 | toolCallId: "call-get", |
| 842 | title: "get_command_or_subagent_output", |
| 843 | status: "completed", |
| 844 | data: { rawInput: { variant: "TaskOutput", task_ids: ["call-bg-5"] } }, |
| 845 | }), |
| 846 | ).toEqual([]); |
| 847 | }); |
| 848 | |
| 849 | it("maps task lifecycle notifications to background mutations", () => { |
| 850 | // task_backgrounded carries the cancelled call's tool_call_id as task_id. |
| 851 | expect( |
| 852 | xAiBackgroundTaskLifecycleMutation( |
| 853 | { |
| 854 | sessionId: "session-1", |
| 855 | update: { |
| 856 | sessionUpdate: "task_backgrounded", |
| 857 | tool_call_id: "call-bg-1", |
| 858 | task_id: "call-bg-1", |
| 859 | }, |
| 860 | }, |
| 861 | "running", |
| 862 | ), |
| 863 | ).toEqual({ sessionId: "session-1", taskId: "call-bg-1", status: "running" }); |
| 864 | // task_completed nests the id inside task_snapshot. |
| 865 | expect( |
| 866 | xAiBackgroundTaskLifecycleMutation( |
| 867 | { |
| 868 | sessionId: "session-1", |
| 869 | update: { |
| 870 | sessionUpdate: "task_completed", |
| 871 | task_snapshot: { task_id: "call-bg-1" }, |
| 872 | }, |
| 873 | }, |
| 874 | "completed", |
| 875 | ), |
| 876 | ).toEqual({ sessionId: "session-1", taskId: "call-bg-1", status: "completed" }); |
| 877 | expect( |
| 878 | xAiBackgroundTaskLifecycleMutation( |
| 879 | { sessionId: "session-1", update: { sessionUpdate: "task_completed" } }, |
| 880 | "completed", |
| 881 | ), |
| 882 | ).toBeNull(); |
| 883 | }); |
| 884 | |
| 885 | it.effect("tracks canonical Grok task lifecycle notifications", () => |
| 886 | Effect.gen(function* () { |
| 887 | const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); |
| 888 | const mutations: Array<{ |
| 889 | readonly sessionId: string; |
| 890 | readonly taskId: string; |
| 891 | readonly status: "running" | "completed" | "failed"; |
| 892 | }> = []; |
| 893 | const runtime = { |
| 894 | handleExtNotification: ( |
| 895 | method: string, |
| 896 | _schema: unknown, |
| 897 | handler: (notification: unknown) => Effect.Effect<void>, |
| 898 | ) => { |
| 899 | handlers.set(method, handler); |
| 900 | return Effect.void; |
| 901 | }, |
| 902 | } as unknown as Pick<AcpSessionRuntime.AcpSessionRuntime["Service"], "handleExtNotification">; |
| 903 | |
| 904 | yield* registerXAiBackgroundTaskTracking(runtime, (mutation) => |
| 905 | Effect.sync(() => mutations.push(mutation)), |
| 906 | ); |
| 907 | |
| 908 | const backgrounded = handlers.get("x.ai/task_backgrounded"); |
| 909 | const completed = handlers.get("x.ai/task_completed"); |
| 910 | expect(backgrounded).toBeDefined(); |
| 911 | expect(completed).toBeDefined(); |
| 912 | yield* backgrounded!({ |
| 913 | sessionId: "session-1", |
| 914 | update: { |
| 915 | sessionUpdate: "task_backgrounded", |
| 916 | tool_call_id: "call-bg-1", |
| 917 | task_id: "task-bg-1", |
| 918 | }, |
| 919 | }); |
| 920 | yield* completed!({ |
| 921 | sessionId: "session-1", |
| 922 | update: { |
| 923 | sessionUpdate: "task_completed", |
| 924 | task_snapshot: { task_id: "task-bg-1" }, |
| 925 | }, |
| 926 | }); |
| 927 | expect(mutations).toEqual([ |
| 928 | { sessionId: "session-1", taskId: "task-bg-1", status: "running" }, |
| 929 | { sessionId: "session-1", taskId: "task-bg-1", status: "completed" }, |
| 930 | ]); |
| 931 | expect(handlers.has("_x.ai/task_backgrounded")).toBe(true); |
| 932 | expect(handlers.has("_x.ai/task_completed")).toBe(true); |
| 933 | }), |
| 934 | ); |
| 935 | |
| 936 | it("detects persistent Monitor start ACKs", () => { |
| 937 | expect( |
| 938 | isXAiPersistentMonitor({ |
| 939 | toolCallId: "call-mon-persist", |
| 940 | title: "Monitor", |
| 941 | status: "completed", |
| 942 | data: { |
| 943 | rawInput: { variant: "Monitor" }, |
| 944 | rawOutput: { |
| 945 | type: "Monitor", |
| 946 | taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", |
| 947 | timeoutMs: 36000000, |
| 948 | persistent: true, |
| 949 | }, |
| 950 | }, |
| 951 | }), |
| 952 | ).toBe(true); |
| 953 | expect( |
| 954 | isXAiPersistentMonitor({ |
| 955 | toolCallId: "call-mon-ephemeral", |
| 956 | title: "Monitor", |
| 957 | status: "completed", |
| 958 | data: { |
| 959 | rawInput: { variant: "Monitor" }, |
| 960 | rawOutput: { |
| 961 | type: "Monitor", |
| 962 | taskId: "019f44b8-8e98-7c80-a40e-df1e26a5f9e3", |
| 963 | timeoutMs: 36000000, |
| 964 | persistent: false, |
| 965 | }, |
| 966 | }, |
| 967 | }), |
| 968 | ).toBe(false); |
| 969 | }); |
| 970 | |
| 971 | it("keeps a standalone TaskOutput completion terminal through normalize", () => { |
| 972 | // Must remain offer evidence post-settle: normalize must not demote it to |
| 973 | // inProgress the way monitor start ACKs are. |
| 974 | expect( |
| 975 | normalizeXAiAcpToolCallState({ |
| 976 | toolCallId: "call-c29e64dd-ce5d-4eac-a6ca-c20513fefac3-1", |
| 977 | title: "[monitor] Stream numbered markers every 3s (019f54a0)", |
| 978 | status: "completed", |
| 979 | data: { |
| 980 | rawOutput: { |
| 981 | type: "TaskOutput", |
| 982 | Result: { |
| 983 | task_id: "019f54a0-06a8-77f2-8214-e24937cad564", |
| 984 | status: "completed", |
| 985 | exit_code: 0, |
| 986 | output: "STREAM_DONE\n", |
| 987 | }, |
| 988 | }, |
| 989 | }, |
| 990 | }).status, |
| 991 | ).toBe("completed"); |
| 992 | }); |
| 993 | |
| 994 | it("parses monitor event lines and end reminders", () => { |
| 995 | expect( |
| 996 | extractXAiAcpBackgroundToolMutation( |
| 997 | '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">\n[Demo] mon_line_1\n</monitor-event>', |
| 998 | ), |
| 999 | ).toEqual([ |
| 1000 | { |
| 1001 | taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", |
| 1002 | status: "running", |
| 1003 | appendOutput: "[Demo] mon_line_1\n", |
| 1004 | }, |
| 1005 | ]); |
| 1006 | expect( |
| 1007 | extractXAiAcpBackgroundToolMutation( |
| 1008 | [ |
| 1009 | "<system-reminder>", |
| 1010 | 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].', |
| 1011 | "Description: Demo monitor", |
| 1012 | "</system-reminder>", |
| 1013 | ].join("\n"), |
| 1014 | ), |
| 1015 | ).toMatchObject([ |
| 1016 | { |
| 1017 | taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", |
| 1018 | status: "completed", |
| 1019 | }, |
| 1020 | ]); |
| 1021 | // Description/output may contain "error" without meaning the monitor failed. |
| 1022 | expect( |
| 1023 | extractXAiAcpBackgroundToolMutation( |
| 1024 | [ |
| 1025 | "<system-reminder>", |
| 1026 | 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].', |
| 1027 | "Description: watch error logs for failed deploys", |
| 1028 | "error: nothing found", |
| 1029 | "</system-reminder>", |
| 1030 | ].join("\n"), |
| 1031 | ), |
| 1032 | ).toMatchObject([ |
| 1033 | { |
| 1034 | taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", |
| 1035 | status: "completed", |
| 1036 | }, |
| 1037 | ]); |
| 1038 | }); |
| 1039 | |
| 1040 | it("parses every monitor-event and trailing end notice in one chunk", () => { |
| 1041 | const mutations = extractXAiAcpBackgroundToolMutation( |
| 1042 | [ |
| 1043 | '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">', |
| 1044 | "[Demo] mon_line_1", |
| 1045 | "</monitor-event>", |
| 1046 | '<monitor-event task_id="019f44a5-87d1-7640-8e35-6a4667ffc873">', |
| 1047 | "[Demo] mon_line_2", |
| 1048 | "</monitor-event>", |
| 1049 | 'Monitor "019f44a5-87d1-7640-8e35-6a4667ffc873" ended: [monitor ended: exited (code 0)].', |
| 1050 | ].join("\n"), |
| 1051 | ); |
| 1052 | expect(mutations).toHaveLength(3); |
| 1053 | expect(mutations[0]).toEqual({ |
| 1054 | taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", |
| 1055 | status: "running", |
| 1056 | appendOutput: "[Demo] mon_line_1\n", |
| 1057 | }); |
| 1058 | expect(mutations[1]).toEqual({ |
| 1059 | taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", |
| 1060 | status: "running", |
| 1061 | appendOutput: "[Demo] mon_line_2\n", |
| 1062 | }); |
| 1063 | expect(mutations[2]).toMatchObject({ |
| 1064 | taskId: "019f44a5-87d1-7640-8e35-6a4667ffc873", |
| 1065 | status: "completed", |
| 1066 | }); |
| 1067 | }); |
| 1068 | |
| 1069 | it("parses batched monitor event blocks as running mutations", () => { |
| 1070 | expect( |
| 1071 | extractXAiAcpBackgroundToolMutation( |
| 1072 | [ |
| 1073 | "3 monitor events from 1 monitor (use get_command_or_subagent_output to identify each monitor):", |
| 1074 | "", |
| 1075 | '<monitor description="Stream STREAM_i every 3s" task_id="019f545d-4d39-7001-b1c8-c7744c448ec1">', |
| 1076 | "[1] STREAM_7", |
| 1077 | "[2] STREAM_8", |
| 1078 | "[3] STREAM_9", |
| 1079 | "</monitor>", |
| 1080 | ].join("\n"), |
| 1081 | ), |
| 1082 | ).toEqual([ |
| 1083 | { |
| 1084 | taskId: "019f545d-4d39-7001-b1c8-c7744c448ec1", |
| 1085 | status: "running", |
| 1086 | appendOutput: "[1] STREAM_7\n[2] STREAM_8\n[3] STREAM_9\n", |
| 1087 | }, |
| 1088 | ]); |
| 1089 | }); |
| 1090 | |
| 1091 | it("parses the subagent completed reminder as an end notice", () => { |
| 1092 | expect( |
| 1093 | extractXAiAcpSubagentEndNotice( |
| 1094 | [ |
| 1095 | "<system-reminder>", |
| 1096 | 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") completed successfully.', |
| 1097 | "Duration: 28.8s | Tool calls: 1 | Turns: 1", |
| 1098 | 'Use get_task_output("019f5470-bf92-7a90-afb3-5a6cea5b34a3") to see the full output.', |
| 1099 | "</system-reminder>", |
| 1100 | ].join("\n"), |
| 1101 | ), |
| 1102 | ).toEqual({ |
| 1103 | childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3", |
| 1104 | status: "completed", |
| 1105 | }); |
| 1106 | }); |
| 1107 | |
| 1108 | it("parses a failed subagent reminder as a failed end notice", () => { |
| 1109 | expect( |
| 1110 | extractXAiAcpSubagentEndNotice( |
| 1111 | 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") failed.', |
| 1112 | ), |
| 1113 | ).toEqual({ |
| 1114 | childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3", |
| 1115 | status: "failed", |
| 1116 | }); |
| 1117 | }); |
| 1118 | |
| 1119 | it("ignores prompt text that merely mentions subagents", () => { |
| 1120 | expect( |
| 1121 | extractXAiAcpSubagentEndNotice("Live-test post-settle subagent completion."), |
| 1122 | ).toBeUndefined(); |
| 1123 | expect( |
| 1124 | extractXAiAcpSubagentEndNotice( |
| 1125 | 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Run sleep then echo token") is still running.', |
| 1126 | ), |
| 1127 | ).toBeUndefined(); |
| 1128 | }); |
| 1129 | |
| 1130 | it("does not treat nested parentheses in the title as the outcome verb", () => { |
| 1131 | expect( |
| 1132 | extractXAiAcpSubagentEndNotice( |
| 1133 | 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Check (backend) failed tests") is still running.', |
| 1134 | ), |
| 1135 | ).toBeUndefined(); |
| 1136 | expect( |
| 1137 | extractXAiAcpSubagentEndNotice( |
| 1138 | 'Background subagent "019f5470-bf92-7a90-afb3-5a6cea5b34a3" (general-purpose: "Check (backend) failed tests") completed successfully.', |
| 1139 | ), |
| 1140 | ).toEqual({ |
| 1141 | childSessionId: "019f5470-bf92-7a90-afb3-5a6cea5b34a3", |
| 1142 | status: "completed", |
| 1143 | }); |
| 1144 | }); |
| 1145 | |
| 1146 | it("extracts questions from the real xAI ask_user_question payload shape", () => { |
| 1147 | const questions = extractXAiAskUserQuestions({ |
| 1148 | sessionId: "session-1", |
| 1149 | toolCallId: "tool-call-1", |
| 1150 | mode: "default", |
| 1151 | questions: [ |
| 1152 | { |
| 1153 | id: "scope", |
| 1154 | question: "Which scope should Grok use?", |
| 1155 | options: [ |
| 1156 | { label: "Workspace", description: "Use the current workspace" }, |
| 1157 | { label: "Session", description: "Only use this session" }, |
| 1158 | ], |
| 1159 | }, |
| 1160 | ], |
| 1161 | }); |
| 1162 | |
| 1163 | expect(questions).toEqual([ |
| 1164 | { |
| 1165 | id: "scope", |
| 1166 | header: "Question", |
| 1167 | question: "Which scope should Grok use?", |
| 1168 | multiSelect: false, |
| 1169 | options: [ |
| 1170 | { label: "Workspace", description: "Use the current workspace" }, |
| 1171 | { label: "Session", description: "Only use this session" }, |
| 1172 | ], |
| 1173 | }, |
| 1174 | ]); |
| 1175 | }); |
| 1176 | |
| 1177 | it("extracts questions from wrapped _x.ai extension payloads", () => { |
| 1178 | const payload = { |
| 1179 | method: "_x.ai/ask_user_question", |
| 1180 | params: { |
| 1181 | sessionId: "session-1", |
| 1182 | toolCallId: "tool-call-1", |
| 1183 | mode: "plan", |
| 1184 | questions: [ |
| 1185 | { |
| 1186 | question: "Which changes should be included?", |
| 1187 | multiSelect: true, |
| 1188 | options: [{ label: "Tests" }, { label: "Docs" }], |
| 1189 | }, |
| 1190 | ], |
| 1191 | }, |
| 1192 | }; |
| 1193 | const decoded = decodeXAiAskUserQuestionRequest(payload); |
| 1194 | const questions = extractXAiAskUserQuestions(decoded); |
| 1195 | |
| 1196 | expect(questions).toEqual([ |
| 1197 | { |
| 1198 | id: "Which changes should be included?", |
| 1199 | header: "Question", |
| 1200 | question: "Which changes should be included?", |
| 1201 | multiSelect: true, |
| 1202 | options: [ |
| 1203 | { label: "Tests", description: "Tests" }, |
| 1204 | { label: "Docs", description: "Docs" }, |
| 1205 | ], |
| 1206 | }, |
| 1207 | ]); |
| 1208 | }); |
| 1209 | |
| 1210 | it("treats nullable multiSelect from Grok as single-select", () => { |
| 1211 | const questions = extractXAiAskUserQuestions({ |
| 1212 | sessionId: "session-1", |
| 1213 | toolCallId: "tool-call-1", |
| 1214 | mode: "default", |
| 1215 | questions: [ |
| 1216 | { |
| 1217 | question: "Which label should Grok use?", |
| 1218 | multiSelect: null, |
| 1219 | options: [ |
| 1220 | { label: "Alpha", description: "Use the Alpha label" }, |
| 1221 | { label: "Beta", description: "Use the Beta label" }, |
| 1222 | { label: "Other", description: "Use the Other label" }, |
| 1223 | ], |
| 1224 | }, |
| 1225 | ], |
| 1226 | }); |
| 1227 | |
| 1228 | expect(questions).toEqual([ |
| 1229 | { |
| 1230 | id: "Which label should Grok use?", |
| 1231 | header: "Question", |
| 1232 | question: "Which label should Grok use?", |
| 1233 | multiSelect: false, |
| 1234 | options: [ |
| 1235 | { label: "Alpha", description: "Use the Alpha label" }, |
| 1236 | { label: "Beta", description: "Use the Beta label" }, |
| 1237 | { label: "Other", description: "Use the Other label" }, |
| 1238 | ], |
| 1239 | }, |
| 1240 | ]); |
| 1241 | }); |
| 1242 | |
| 1243 | it("maps UI question ids back to xAI question text in accepted responses", () => { |
| 1244 | const response = makeXAiAskUserQuestionResponse( |
| 1245 | { |
| 1246 | sessionId: "session-1", |
| 1247 | toolCallId: "tool-call-1", |
| 1248 | mode: "default", |
| 1249 | questions: [ |
| 1250 | { |
| 1251 | id: "scope", |
| 1252 | question: "Which scope should Grok use?", |
| 1253 | options: [ |
| 1254 | { label: "workspace", description: "Use the current workspace" }, |
| 1255 | { label: "session", description: "Only use this session" }, |
| 1256 | ], |
| 1257 | }, |
| 1258 | ], |
| 1259 | }, |
| 1260 | { scope: "workspace" }, |
| 1261 | ); |
| 1262 | |
| 1263 | expect(response).toEqual({ |
| 1264 | outcome: "accepted", |
| 1265 | answers: { |
| 1266 | "Which scope should Grok use?": ["workspace"], |
| 1267 | }, |
| 1268 | }); |
| 1269 | }); |
| 1270 | |
| 1271 | it("orders accepted answers by the original xAI question order", () => { |
| 1272 | const response = makeXAiAskUserQuestionResponse( |
| 1273 | { |
| 1274 | sessionId: "session-1", |
| 1275 | toolCallId: "tool-call-1", |
| 1276 | mode: "default", |
| 1277 | questions: [ |
| 1278 | { |
| 1279 | id: "first", |
| 1280 | question: "First question?", |
| 1281 | options: [{ label: "A", description: "A" }], |
| 1282 | }, |
| 1283 | { |
| 1284 | id: "second", |
| 1285 | question: "Second question?", |
| 1286 | options: [{ label: "B", description: "B" }], |
| 1287 | }, |
| 1288 | ], |
| 1289 | }, |
| 1290 | { |
| 1291 | second: "B", |
| 1292 | first: "A", |
| 1293 | }, |
| 1294 | ); |
| 1295 | |
| 1296 | expect(Object.keys(response.answers)).toEqual(["First question?", "Second question?"]); |
| 1297 | expect(response).toMatchObject({ |
| 1298 | outcome: "accepted", |
| 1299 | answers: { |
| 1300 | "First question?": ["A"], |
| 1301 | "Second question?": ["B"], |
| 1302 | }, |
| 1303 | }); |
| 1304 | }); |
| 1305 | |
| 1306 | it("encodes typed custom answers as xAI Other annotations", () => { |
| 1307 | const response = makeXAiAskUserQuestionResponse( |
| 1308 | { |
| 1309 | method: "x.ai/ask_user_question", |
| 1310 | params: { |
| 1311 | sessionId: "session-1", |
| 1312 | toolCallId: "tool-call-1", |
| 1313 | mode: "default", |
| 1314 | questions: [ |
| 1315 | { |
| 1316 | question: "Which ice cream flavor?", |
| 1317 | options: [ |
| 1318 | { label: "vanilla", description: "Vanilla flavor" }, |
| 1319 | { label: "chocolate", description: "Chocolate flavor" }, |
| 1320 | ], |
| 1321 | }, |
| 1322 | ], |
| 1323 | }, |
| 1324 | }, |
| 1325 | { "Which ice cream flavor?": "pistachio" }, |
| 1326 | ); |
| 1327 | |
| 1328 | expect(response).toEqual({ |
| 1329 | outcome: "accepted", |
| 1330 | answers: { |
| 1331 | "Which ice cream flavor?": ["Other"], |
| 1332 | }, |
| 1333 | annotations: { |
| 1334 | "Which ice cream flavor?": { |
| 1335 | notes: "pistachio", |
| 1336 | }, |
| 1337 | }, |
| 1338 | }); |
| 1339 | }); |
| 1340 | |
| 1341 | it("does not echo preview annotations for multi-select answers", () => { |
| 1342 | const response = makeXAiAskUserQuestionResponse( |
| 1343 | { |
| 1344 | sessionId: "session-1", |
| 1345 | toolCallId: "tool-call-1", |
| 1346 | mode: "default", |
| 1347 | questions: [ |
| 1348 | { |
| 1349 | question: "Which files should Grok touch?", |
| 1350 | multiSelect: true, |
| 1351 | options: [ |
| 1352 | { |
| 1353 | label: "Tests", |
| 1354 | description: "Update tests", |
| 1355 | preview: "test preview", |
| 1356 | }, |
| 1357 | { |
| 1358 | label: "Docs", |
| 1359 | description: "Update docs", |
| 1360 | preview: "docs preview", |
| 1361 | }, |
| 1362 | ], |
| 1363 | }, |
| 1364 | ], |
| 1365 | }, |
| 1366 | { "Which files should Grok touch?": ["Tests", "Docs"] }, |
| 1367 | ); |
| 1368 | |
| 1369 | expect(response).toEqual({ |
| 1370 | outcome: "accepted", |
| 1371 | answers: { |
| 1372 | "Which files should Grok touch?": ["Tests", "Docs"], |
| 1373 | }, |
| 1374 | }); |
| 1375 | }); |
| 1376 | |
| 1377 | it.effect("settles a hung prompt from a root-session prompt_complete notification", () => |
| 1378 | Effect.gen(function* () { |
| 1379 | const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); |
| 1380 | const hungPrompt = yield* Deferred.make<never>(); |
| 1381 | const baseRuntime = { |
| 1382 | start: () => |
| 1383 | Effect.succeed({ |
| 1384 | sessionId: "root-session", |
| 1385 | initializeResult: {}, |
| 1386 | sessionSetupResult: {}, |
| 1387 | modelConfigId: undefined, |
| 1388 | }), |
| 1389 | prompt: () => Deferred.await(hungPrompt), |
| 1390 | cancel: Effect.void, |
| 1391 | handleExtNotification: ( |
| 1392 | method: string, |
| 1393 | _schema: unknown, |
| 1394 | handler: (notification: unknown) => Effect.Effect<void>, |
| 1395 | ) => { |
| 1396 | handlers.set(method, handler); |
| 1397 | return Effect.void; |
| 1398 | }, |
| 1399 | handleExtRequest: () => Effect.void, |
| 1400 | } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; |
| 1401 | |
| 1402 | const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); |
| 1403 | const promptFiber = yield* runtime |
| 1404 | .prompt({ prompt: [{ type: "text", text: "hi" }] }) |
| 1405 | .pipe(Effect.forkChild); |
| 1406 | yield* Effect.yieldNow; |
| 1407 | const promptCompleteHandler = handlers.get("x.ai/session/prompt_complete"); |
| 1408 | expect(promptCompleteHandler).toBeDefined(); |
| 1409 | yield* promptCompleteHandler!({ |
| 1410 | sessionId: "root-session", |
| 1411 | stopReason: "end_turn", |
| 1412 | }); |
| 1413 | const response = yield* Fiber.join(promptFiber); |
| 1414 | expect(response.stopReason).toBe("end_turn"); |
| 1415 | expect(handlers.has("_x.ai/session/prompt_complete")).toBe(true); |
| 1416 | }), |
| 1417 | ); |
| 1418 | |
| 1419 | it.effect("fails a hung prompt from an xAI rate-limit completion (#8358)", () => |
| 1420 | Effect.gen(function* () { |
| 1421 | const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); |
| 1422 | const hungPrompt = yield* Deferred.make<never>(); |
| 1423 | const baseRuntime = { |
| 1424 | start: () => |
| 1425 | Effect.succeed({ |
| 1426 | sessionId: "root-session", |
| 1427 | initializeResult: {}, |
| 1428 | sessionSetupResult: {}, |
| 1429 | modelConfigId: undefined, |
| 1430 | }), |
| 1431 | prompt: () => Deferred.await(hungPrompt), |
| 1432 | cancel: Effect.void, |
| 1433 | handleExtNotification: ( |
| 1434 | method: string, |
| 1435 | _schema: unknown, |
| 1436 | handler: (notification: unknown) => Effect.Effect<void>, |
| 1437 | ) => { |
| 1438 | handlers.set(method, handler); |
| 1439 | return Effect.void; |
| 1440 | }, |
| 1441 | handleExtRequest: () => Effect.void, |
| 1442 | } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; |
| 1443 | |
| 1444 | const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); |
| 1445 | const promptFiber = yield* runtime |
| 1446 | .prompt({ prompt: [{ type: "text", text: "hi" }] }) |
| 1447 | .pipe(Effect.forkChild); |
| 1448 | yield* Effect.yieldNow; |
| 1449 | |
| 1450 | const promptCompleteHandler = handlers.get("x.ai/session/prompt_complete"); |
| 1451 | expect(promptCompleteHandler).toBeDefined(); |
| 1452 | yield* promptCompleteHandler!({ |
| 1453 | sessionId: "root-session", |
| 1454 | stopReason: "rate_limit", |
| 1455 | }); |
| 1456 | const error = yield* Fiber.join(promptFiber).pipe(Effect.flip); |
| 1457 | expect(error).toMatchObject({ |
| 1458 | _tag: "AcpRequestError", |
| 1459 | code: -32003, |
| 1460 | errorMessage: "Grok usage limit reached. Try again later.", |
| 1461 | }); |
| 1462 | }), |
| 1463 | ); |
| 1464 | |
| 1465 | it("maps subagent_finished to a notice only for statuses Grok defines", () => { |
| 1466 | // Recorded from Grok 1.0.41 (ids normalized); statuses per grok-build |
| 1467 | // xai-grok-tools/.../task/types.rs `status()`. |
| 1468 | const finished = (update: Record<string, unknown>) => |
| 1469 | xAiSubagentFinishedNotice({ |
| 1470 | sessionId: "root-session", |
| 1471 | update: { |
| 1472 | sessionUpdate: "subagent_finished", |
| 1473 | subagent_id: "child-session", |
| 1474 | child_session_id: "child-session", |
| 1475 | tool_calls: 2, |
| 1476 | turns: 1, |
| 1477 | duration_ms: 23734, |
| 1478 | ...update, |
| 1479 | } as never, |
| 1480 | }); |
| 1481 | expect(finished({ status: "completed", output: "SUBAGENT_DONE", will_wake: true })).toEqual({ |
| 1482 | sessionId: "root-session", |
| 1483 | childSessionId: "child-session", |
| 1484 | status: "completed", |
| 1485 | result: "SUBAGENT_DONE", |
| 1486 | }); |
| 1487 | expect(finished({ status: "failed", error: "tool crashed" })).toMatchObject({ |
| 1488 | status: "failed", |
| 1489 | result: "tool crashed", |
| 1490 | }); |
| 1491 | expect( |
| 1492 | finished({ status: "cancelled", error: "interrupted by process restart" }), |
| 1493 | ).toMatchObject({ status: "cancelled", result: "interrupted by process restart" }); |
| 1494 | // A failed subagent's error is its result; `output` only accompanies success. |
| 1495 | expect(finished({ status: "failed", output: "partial" })).toMatchObject({ result: null }); |
| 1496 | expect(finished({ status: "timed_out" })).toBeNull(); |
| 1497 | expect(finished({})).toBeNull(); |
| 1498 | expect(finished({ status: "completed", child_session_id: undefined })).toBeNull(); |
| 1499 | }); |
| 1500 | |
| 1501 | it.effect("settles prompts and finishes subagents from the same session notifications", () => |
| 1502 | Effect.gen(function* () { |
| 1503 | const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); |
| 1504 | let capturedMeta: Record<string, unknown> | null | undefined; |
| 1505 | const hungPrompt = yield* Deferred.make<never>(); |
| 1506 | const baseRuntime = { |
| 1507 | start: () => |
| 1508 | Effect.succeed({ |
| 1509 | sessionId: "root-session", |
| 1510 | initializeResult: {}, |
| 1511 | sessionSetupResult: {}, |
| 1512 | modelConfigId: undefined, |
| 1513 | }), |
| 1514 | prompt: (payload: { readonly _meta?: Record<string, unknown> | null }) => { |
| 1515 | capturedMeta = payload._meta ?? null; |
| 1516 | return Deferred.await(hungPrompt); |
| 1517 | }, |
| 1518 | cancel: Effect.void, |
| 1519 | handleExtNotification: ( |
| 1520 | method: string, |
| 1521 | _schema: unknown, |
| 1522 | handler: (notification: unknown) => Effect.Effect<void>, |
| 1523 | ) => { |
| 1524 | handlers.set(method, handler); |
| 1525 | return Effect.void; |
| 1526 | }, |
| 1527 | } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; |
| 1528 | const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); |
| 1529 | const notices: Array<unknown> = []; |
| 1530 | // Registered after the wrapper, as the adapter does: it must not replace |
| 1531 | // the wrapper's prompt completion on the shared method. |
| 1532 | yield* registerXAiSubagentFinished(runtime, (notice) => |
| 1533 | Effect.sync(() => notices.push(notice)), |
| 1534 | ); |
| 1535 | const sessionNotification = handlers.get("_x.ai/session_notification")!; |
| 1536 | yield* sessionNotification({ |
| 1537 | sessionId: "root-session", |
| 1538 | update: { |
| 1539 | sessionUpdate: "subagent_finished", |
| 1540 | child_session_id: "child-session", |
| 1541 | status: "completed", |
| 1542 | output: "SUBAGENT_DONE", |
| 1543 | }, |
| 1544 | }); |
| 1545 | expect(notices).toEqual([ |
| 1546 | { |
| 1547 | sessionId: "root-session", |
| 1548 | childSessionId: "child-session", |
| 1549 | status: "completed", |
| 1550 | result: "SUBAGENT_DONE", |
| 1551 | }, |
| 1552 | ]); |
| 1553 | |
| 1554 | const promptFiber = yield* runtime |
| 1555 | .prompt({ prompt: [{ type: "text", text: "hi" }] }) |
| 1556 | .pipe(Effect.forkChild); |
| 1557 | yield* Effect.yieldNow; |
| 1558 | yield* sessionNotification({ |
| 1559 | sessionId: "root-session", |
| 1560 | update: { |
| 1561 | sessionUpdate: "turn_completed", |
| 1562 | prompt_id: capturedMeta?.promptId, |
| 1563 | stop_reason: "end_turn", |
| 1564 | }, |
| 1565 | }); |
| 1566 | const response = yield* Fiber.join(promptFiber); |
| 1567 | expect(response.stopReason).toBe("end_turn"); |
| 1568 | expect(notices).toHaveLength(1); |
| 1569 | }), |
| 1570 | ); |
| 1571 | |
| 1572 | it.effect("settles a hung prompt from _x.ai/session/update turn_completed", () => |
| 1573 | Effect.gen(function* () { |
| 1574 | const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); |
| 1575 | let capturedMeta: Record<string, unknown> | null | undefined; |
| 1576 | const hungPrompt = yield* Deferred.make<never>(); |
| 1577 | const baseRuntime = { |
| 1578 | start: () => |
| 1579 | Effect.succeed({ |
| 1580 | sessionId: "root-session", |
| 1581 | initializeResult: {}, |
| 1582 | sessionSetupResult: {}, |
| 1583 | modelConfigId: undefined, |
| 1584 | }), |
| 1585 | prompt: (payload: { readonly _meta?: Record<string, unknown> | null }) => { |
| 1586 | capturedMeta = payload._meta ?? null; |
| 1587 | return Deferred.await(hungPrompt); |
| 1588 | }, |
| 1589 | cancel: Effect.void, |
| 1590 | handleExtNotification: ( |
| 1591 | method: string, |
| 1592 | _schema: unknown, |
| 1593 | handler: (notification: unknown) => Effect.Effect<void>, |
| 1594 | ) => { |
| 1595 | handlers.set(method, handler); |
| 1596 | return Effect.void; |
| 1597 | }, |
| 1598 | handleExtRequest: () => Effect.void, |
| 1599 | } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; |
| 1600 | |
| 1601 | const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); |
| 1602 | const promptFiber = yield* runtime |
| 1603 | .prompt({ prompt: [{ type: "text", text: "hi" }] }) |
| 1604 | .pipe(Effect.forkChild); |
| 1605 | yield* Effect.yieldNow; |
| 1606 | const promptId = capturedMeta?.promptId; |
| 1607 | expect(typeof promptId).toBe("string"); |
| 1608 | const sessionUpdateHandler = handlers.get("_x.ai/session/update"); |
| 1609 | expect(sessionUpdateHandler).toBeDefined(); |
| 1610 | yield* sessionUpdateHandler!({ |
| 1611 | sessionId: "root-session", |
| 1612 | update: { |
| 1613 | sessionUpdate: "turn_completed", |
| 1614 | prompt_id: promptId, |
| 1615 | stop_reason: "end_turn", |
| 1616 | }, |
| 1617 | }); |
| 1618 | const response = yield* Fiber.join(promptFiber); |
| 1619 | expect(response.stopReason).toBe("end_turn"); |
| 1620 | }), |
| 1621 | ); |
| 1622 | |
| 1623 | it.effect("settles a hung prompt from released _x.ai/session_notification turns", () => |
| 1624 | Effect.gen(function* () { |
| 1625 | const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); |
| 1626 | const hungPrompt = yield* Deferred.make<never>(); |
| 1627 | const baseRuntime = { |
| 1628 | start: () => |
| 1629 | Effect.succeed({ |
| 1630 | sessionId: "root-session", |
| 1631 | initializeResult: {}, |
| 1632 | sessionSetupResult: {}, |
| 1633 | modelConfigId: undefined, |
| 1634 | }), |
| 1635 | prompt: () => Deferred.await(hungPrompt), |
| 1636 | cancel: Effect.void, |
| 1637 | handleExtNotification: ( |
| 1638 | method: string, |
| 1639 | _schema: unknown, |
| 1640 | handler: (notification: unknown) => Effect.Effect<void>, |
| 1641 | ) => { |
| 1642 | handlers.set(method, handler); |
| 1643 | return Effect.void; |
| 1644 | }, |
| 1645 | handleExtRequest: () => Effect.void, |
| 1646 | } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; |
| 1647 | |
| 1648 | const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); |
| 1649 | const promptFiber = yield* runtime |
| 1650 | .prompt({ prompt: [{ type: "text", text: "hi" }] }) |
| 1651 | .pipe(Effect.forkChild); |
| 1652 | yield* Effect.yieldNow; |
| 1653 | const sessionNotificationHandler = handlers.get("_x.ai/session_notification"); |
| 1654 | expect(sessionNotificationHandler).toBeDefined(); |
| 1655 | yield* sessionNotificationHandler!({ |
| 1656 | sessionId: "root-session", |
| 1657 | update: { |
| 1658 | sessionUpdate: "turn_completed", |
| 1659 | prompt_id: "t3-xai-prompt-1", |
| 1660 | stop_reason: "end_turn", |
| 1661 | }, |
| 1662 | }); |
| 1663 | const response = yield* Fiber.join(promptFiber); |
| 1664 | expect(response.stopReason).toBe("end_turn"); |
| 1665 | expect(handlers.has("_x.ai/session/update")).toBe(true); |
| 1666 | }), |
| 1667 | ); |
| 1668 | |
| 1669 | it.effect("ignores turn_completed for non-pending prompt ids and task completions", () => |
| 1670 | Effect.gen(function* () { |
| 1671 | const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); |
| 1672 | const hungPrompt = yield* Deferred.make<never>(); |
| 1673 | const baseRuntime = { |
| 1674 | start: () => |
| 1675 | Effect.succeed({ |
| 1676 | sessionId: "root-session", |
| 1677 | initializeResult: {}, |
| 1678 | sessionSetupResult: {}, |
| 1679 | modelConfigId: undefined, |
| 1680 | }), |
| 1681 | prompt: () => Deferred.await(hungPrompt), |
| 1682 | cancel: Effect.void, |
| 1683 | handleExtNotification: ( |
| 1684 | method: string, |
| 1685 | _schema: unknown, |
| 1686 | handler: (notification: unknown) => Effect.Effect<void>, |
| 1687 | ) => { |
| 1688 | handlers.set(method, handler); |
| 1689 | return Effect.void; |
| 1690 | }, |
| 1691 | handleExtRequest: () => Effect.void, |
| 1692 | } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; |
| 1693 | |
| 1694 | const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); |
| 1695 | const promptFiber = yield* runtime |
| 1696 | .prompt({ prompt: [{ type: "text", text: "hi" }] }) |
| 1697 | .pipe(Effect.forkChild); |
| 1698 | yield* Effect.yieldNow; |
| 1699 | const sessionUpdateHandler = handlers.get("_x.ai/session/update"); |
| 1700 | expect(sessionUpdateHandler).toBeDefined(); |
| 1701 | yield* sessionUpdateHandler!({ |
| 1702 | sessionId: "root-session", |
| 1703 | update: { |
| 1704 | sessionUpdate: "turn_completed", |
| 1705 | prompt_id: "task-completed-call-abc", |
| 1706 | stop_reason: "end_turn", |
| 1707 | }, |
| 1708 | }); |
| 1709 | yield* sessionUpdateHandler!({ |
| 1710 | sessionId: "root-session", |
| 1711 | update: { |
| 1712 | sessionUpdate: "turn_completed", |
| 1713 | prompt_id: "some-other-cli-turn", |
| 1714 | stop_reason: "end_turn", |
| 1715 | }, |
| 1716 | }); |
| 1717 | yield* Effect.yieldNow; |
| 1718 | expect(promptFiber.pollUnsafe()).toBeUndefined(); |
| 1719 | yield* Fiber.interrupt(promptFiber); |
| 1720 | }), |
| 1721 | ); |
| 1722 | |
| 1723 | it.effect("ignores prompt_complete notifications for foreign session ids", () => |
| 1724 | Effect.gen(function* () { |
| 1725 | const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); |
| 1726 | const hungPrompt = yield* Deferred.make<never>(); |
| 1727 | const baseRuntime = { |
| 1728 | start: () => |
| 1729 | Effect.succeed({ |
| 1730 | sessionId: "root-session", |
| 1731 | initializeResult: {}, |
| 1732 | sessionSetupResult: {}, |
| 1733 | modelConfigId: undefined, |
| 1734 | }), |
| 1735 | prompt: () => Deferred.await(hungPrompt), |
| 1736 | cancel: Effect.void, |
| 1737 | handleExtNotification: ( |
| 1738 | method: string, |
| 1739 | _schema: unknown, |
| 1740 | handler: (notification: unknown) => Effect.Effect<void>, |
| 1741 | ) => { |
| 1742 | handlers.set(method, handler); |
| 1743 | return Effect.void; |
| 1744 | }, |
| 1745 | handleExtRequest: () => Effect.void, |
| 1746 | } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; |
| 1747 | |
| 1748 | const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); |
| 1749 | const promptFiber = yield* runtime |
| 1750 | .prompt({ prompt: [{ type: "text", text: "hi" }] }) |
| 1751 | .pipe(Effect.forkChild); |
| 1752 | yield* Effect.yieldNow; |
| 1753 | const promptCompleteHandler = handlers.get("_x.ai/session/prompt_complete"); |
| 1754 | expect(promptCompleteHandler).toBeDefined(); |
| 1755 | yield* promptCompleteHandler!({ |
| 1756 | sessionId: "child-session", |
| 1757 | }); |
| 1758 | yield* Effect.yieldNow; |
| 1759 | expect(promptFiber.pollUnsafe()).toBeUndefined(); |
| 1760 | yield* Fiber.interrupt(promptFiber); |
| 1761 | }), |
| 1762 | ); |
| 1763 | |
| 1764 | it.effect( |
| 1765 | "ignores promptId-less prompt_complete when multiple prompts are pending on the same session", |
| 1766 | () => |
| 1767 | Effect.gen(function* () { |
| 1768 | const handlers = new Map<string, (notification: unknown) => Effect.Effect<void>>(); |
| 1769 | const hungPrompt = yield* Deferred.make<never>(); |
| 1770 | const baseRuntime = { |
| 1771 | start: () => |
| 1772 | Effect.succeed({ |
| 1773 | sessionId: "root-session", |
| 1774 | initializeResult: {}, |
| 1775 | sessionSetupResult: {}, |
| 1776 | modelConfigId: undefined, |
| 1777 | }), |
| 1778 | prompt: () => Deferred.await(hungPrompt), |
| 1779 | cancel: Effect.void, |
| 1780 | handleExtNotification: ( |
| 1781 | method: string, |
| 1782 | _schema: unknown, |
| 1783 | handler: (notification: unknown) => Effect.Effect<void>, |
| 1784 | ) => { |
| 1785 | handlers.set(method, handler); |
| 1786 | return Effect.void; |
| 1787 | }, |
| 1788 | handleExtRequest: () => Effect.void, |
| 1789 | } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; |
| 1790 | |
| 1791 | const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); |
| 1792 | const firstPromptFiber = yield* runtime |
| 1793 | .prompt({ prompt: [{ type: "text", text: "first" }] }) |
| 1794 | .pipe(Effect.forkChild); |
| 1795 | yield* Effect.yieldNow; |
| 1796 | const secondPromptFiber = yield* runtime |
| 1797 | .prompt({ prompt: [{ type: "text", text: "second" }] }) |
| 1798 | .pipe(Effect.forkChild); |
| 1799 | yield* Effect.yieldNow; |
| 1800 | const promptCompleteHandler = handlers.get("_x.ai/session/prompt_complete"); |
| 1801 | expect(promptCompleteHandler).toBeDefined(); |
| 1802 | yield* promptCompleteHandler!({ |
| 1803 | sessionId: "root-session", |
| 1804 | stopReason: "end_turn", |
| 1805 | }); |
| 1806 | yield* Effect.yieldNow; |
| 1807 | expect(firstPromptFiber.pollUnsafe()).toBeUndefined(); |
| 1808 | expect(secondPromptFiber.pollUnsafe()).toBeUndefined(); |
| 1809 | yield* Fiber.interrupt(firstPromptFiber); |
| 1810 | yield* Fiber.interrupt(secondPromptFiber); |
| 1811 | }), |
| 1812 | ); |
| 1813 | |
| 1814 | it.effect("injects promptId and requestId into prompt _meta", () => |
| 1815 | Effect.gen(function* () { |
| 1816 | let capturedMeta: Record<string, unknown> | null | undefined; |
| 1817 | const baseRuntime = { |
| 1818 | start: () => Effect.succeed({ sessionId: "session-1" }), |
| 1819 | prompt: (payload: { readonly _meta?: Record<string, unknown> | null }) => { |
| 1820 | capturedMeta = payload._meta ?? null; |
| 1821 | return Effect.succeed({ stopReason: "end_turn" as const }); |
| 1822 | }, |
| 1823 | cancel: Effect.void, |
| 1824 | handleExtNotification: () => Effect.void, |
| 1825 | handleExtRequest: () => Effect.void, |
| 1826 | } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; |
| 1827 | |
| 1828 | const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); |
| 1829 | yield* runtime.prompt({ prompt: [{ type: "text", text: "hi" }] }); |
| 1830 | |
| 1831 | expect(typeof capturedMeta?.promptId).toBe("string"); |
| 1832 | expect(capturedMeta).toMatchObject({ |
| 1833 | promptId: capturedMeta?.promptId, |
| 1834 | requestId: capturedMeta?.promptId, |
| 1835 | }); |
| 1836 | }), |
| 1837 | ); |
| 1838 | |
| 1839 | it.effect("cancels pending completions without restarting a stalled runtime", () => |
| 1840 | Effect.gen(function* () { |
| 1841 | const hungStart = yield* Deferred.make<never>(); |
| 1842 | const hungPrompt = yield* Deferred.make<never>(); |
| 1843 | let startCount = 0; |
| 1844 | let cancelCalled = false; |
| 1845 | const baseRuntime = { |
| 1846 | start: () => { |
| 1847 | startCount += 1; |
| 1848 | return startCount === 1 |
| 1849 | ? Effect.succeed({ |
| 1850 | sessionId: "root-session", |
| 1851 | initializeResult: {}, |
| 1852 | sessionSetupResult: {}, |
| 1853 | modelConfigId: undefined, |
| 1854 | }) |
| 1855 | : Deferred.await(hungStart); |
| 1856 | }, |
| 1857 | prompt: () => Deferred.await(hungPrompt), |
| 1858 | cancel: Effect.sync(() => { |
| 1859 | cancelCalled = true; |
| 1860 | }), |
| 1861 | handleExtNotification: () => Effect.void, |
| 1862 | handleExtRequest: () => Effect.void, |
| 1863 | } as unknown as AcpSessionRuntime.AcpSessionRuntime["Service"]; |
| 1864 | |
| 1865 | const runtime = yield* makeXAiPromptCompletionRuntime(baseRuntime); |
| 1866 | const promptFiber = yield* runtime |
| 1867 | .prompt({ prompt: [{ type: "text", text: "hi" }] }) |
| 1868 | .pipe(Effect.forkChild); |
| 1869 | yield* Effect.yieldNow; |
| 1870 | |
| 1871 | yield* runtime.cancel; |
| 1872 | |
| 1873 | expect(cancelCalled).toBe(true); |
| 1874 | expect(startCount).toBe(1); |
| 1875 | expect((yield* Fiber.join(promptFiber)).stopReason).toBe("cancelled"); |
| 1876 | }), |
| 1877 | ); |
| 1878 | }); |
| 1879 | |
| 1880 | describe("Grok exit_plan_mode capture (#8358)", () => { |
| 1881 | it("extracts plan markdown from exit_plan_mode payloads", () => { |
| 1882 | const decode = Schema.decodeUnknownSync(XAiExitPlanModeRequest); |
| 1883 | const direct = decode({ |
| 1884 | sessionId: "session-1", |
| 1885 | toolCallId: "exit-1", |
| 1886 | planContent: "# Plan\n\n- do the thing\n", |
| 1887 | }); |
| 1888 | expect(extractXAiExitPlanMarkdown(direct)).toBe("# Plan\n\n- do the thing"); |
| 1889 | |
| 1890 | const wrapped = decode({ |
| 1891 | method: "_x.ai/exit_plan_mode", |
| 1892 | params: { |
| 1893 | sessionId: "session-1", |
| 1894 | toolCallId: "exit-1", |
| 1895 | planContent: null, |
| 1896 | }, |
| 1897 | }); |
| 1898 | expect(extractXAiExitPlanMarkdown(wrapped, " # fallback plan ")).toBe("# fallback plan"); |
| 1899 | expect(extractXAiExitPlanMarkdown(wrapped, "")).toBe(XAI_EMPTY_PLAN_MARKDOWN); |
| 1900 | expect(extractXAiExitPlanMarkdown(wrapped)).toBe(XAI_EMPTY_PLAN_MARKDOWN); |
| 1901 | }); |
| 1902 | it("identifies Grok plan.md paths and extracts markdown from tool call data", () => { |
| 1903 | const home = "/home/ada"; |
| 1904 | const linuxHost = { platform: "linux" as const, environment: {}, homeDirectory: home }; |
| 1905 | const windowsHost = { platform: "win32" as const, environment: {}, homeDirectory: home }; |
| 1906 | const grokHomeHost = { |
| 1907 | platform: "linux" as const, |
| 1908 | environment: { GROK_HOME: "/opt/grok-data" }, |
| 1909 | homeDirectory: home, |
| 1910 | }; |
| 1911 | const sessionPlan = `${home}/.grok/sessions/abc/plan.md`; |
| 1912 | const nestedSessionPlan = `${home}/.grok/sessions/%2Fhome%2Fproj/019fd20e-c563-70a0-b801-a6bc51815a9b/plan.md`; |
| 1913 | expect(isGrokPlanMarkdownPath(sessionPlan, linuxHost)).toBe(true); |
| 1914 | expect(isGrokPlanMarkdownPath(nestedSessionPlan, linuxHost)).toBe(true); |
| 1915 | expect(isGrokPlanMarkdownPath("~/.grok/sessions/abc/plan.md", linuxHost)).toBe(true); |
| 1916 | expect(isGrokPlanMarkdownPath("/tmp/mock-home/.grok/sessions/sess/plan.md", linuxHost)).toBe( |
| 1917 | true, |
| 1918 | ); |
| 1919 | expect(isGrokPlanMarkdownPath("/home/other/.grok/sessions/sess/plan.md", linuxHost)).toBe(true); |
| 1920 | expect(isGrokPlanMarkdownPath("/HOME/other/.grok/sessions/sess/plan.md", linuxHost)).toBe( |
| 1921 | false, |
| 1922 | ); |
| 1923 | expect(isGrokPlanMarkdownPath("C:/Users/other/.grok/sessions/id/plan.md", windowsHost)).toBe( |
| 1924 | true, |
| 1925 | ); |
| 1926 | expect(isGrokPlanMarkdownPath("c:/users/OTHER/.GROK/SESSIONS/id/PLAN.MD", windowsHost)).toBe( |
| 1927 | true, |
| 1928 | ); |
| 1929 | expect( |
| 1930 | isGrokPlanMarkdownPath("C:\\Users\\other\\.grok\\sessions\\id\\plan.md", windowsHost), |
| 1931 | ).toBe(true); |
| 1932 | expect(isGrokPlanMarkdownPath("/opt/grok-data/sessions/sess/plan.md", grokHomeHost)).toBe(true); |
| 1933 | expect( |
| 1934 | isGrokPlanMarkdownPath("/OPT/GROK-DATA/sessions/sess/plan.md", { |
| 1935 | platform: "win32", |
| 1936 | environment: { GROK_HOME: "/opt/grok-data" }, |
| 1937 | homeDirectory: home, |
| 1938 | }), |
| 1939 | ).toBe(true); |
| 1940 | expect(isGrokPlanMarkdownPath("/OPT/GROK-DATA/sessions/sess/plan.md", grokHomeHost)).toBe( |
| 1941 | false, |
| 1942 | ); |
| 1943 | // Workspace plan.md must not be treated as the session plan file. |
| 1944 | expect(isGrokPlanMarkdownPath("plan.md", linuxHost)).toBe(false); |
| 1945 | expect(isGrokPlanMarkdownPath("/repo/docs/plan.md", linuxHost)).toBe(false); |
| 1946 | expect(isGrokPlanMarkdownPath("/tmp/other.md", linuxHost)).toBe(false); |
| 1947 | expect(isGrokPlanMarkdownPath("/repo/.grok/sessions/example/plan.md", linuxHost)).toBe(false); |
| 1948 | expect( |
| 1949 | isGrokPlanMarkdownPath(`${home}/project/.grok/sessions/example/plan.md`, linuxHost), |
| 1950 | ).toBe(false); |
| 1951 | expect( |
| 1952 | isGrokPlanMarkdownPath("/home/other/.grok/sessions/../../project/plan.md", linuxHost), |
| 1953 | ).toBe(false); |
| 1954 | expect( |
| 1955 | isGrokPlanMarkdownPath("/home/other/.grok/sessions/foo/../../../project/plan.md", linuxHost), |
| 1956 | ).toBe(false); |
| 1957 | |
| 1958 | expect( |
| 1959 | extractGrokPlanMarkdownFromToolCallData( |
| 1960 | { |
| 1961 | rawInput: { |
| 1962 | file_path: sessionPlan, |
| 1963 | content: "# From rawInput\n\n- a\n", |
| 1964 | }, |
| 1965 | }, |
| 1966 | linuxHost, |
| 1967 | ), |
| 1968 | ).toBe("# From rawInput\n\n- a"); |
| 1969 | |
| 1970 | expect( |
| 1971 | extractGrokPlanMarkdownFromToolCallData( |
| 1972 | { |
| 1973 | content: [ |
| 1974 | { |
| 1975 | type: "diff", |
| 1976 | path: sessionPlan, |
| 1977 | oldText: "", |
| 1978 | newText: "# From diff\n\n- b\n", |
| 1979 | }, |
| 1980 | ], |
| 1981 | }, |
| 1982 | linuxHost, |
| 1983 | ), |
| 1984 | ).toBe("# From diff\n\n- b"); |
| 1985 | |
| 1986 | expect( |
| 1987 | extractGrokPlanMarkdownFromToolCallData( |
| 1988 | { |
| 1989 | rawInput: { file_path: sessionPlan, content: "" }, |
| 1990 | content: [ |
| 1991 | { |
| 1992 | type: "diff", |
| 1993 | path: sessionPlan, |
| 1994 | oldText: "", |
| 1995 | newText: "# From diff after empty rawInput\n", |
| 1996 | }, |
| 1997 | ], |
| 1998 | }, |
| 1999 | linuxHost, |
| 2000 | ), |
| 2001 | ).toBe("# From diff after empty rawInput"); |
| 2002 | |
| 2003 | expect( |
| 2004 | extractGrokPlanMarkdownFromToolCallData( |
| 2005 | { |
| 2006 | rawInput: { file_path: sessionPlan, content: "" }, |
| 2007 | }, |
| 2008 | linuxHost, |
| 2009 | ), |
| 2010 | ).toBe(""); |
| 2011 | |
| 2012 | expect( |
| 2013 | extractGrokPlanMarkdownFromToolCallData( |
| 2014 | { |
| 2015 | content: [{ type: "diff", path: sessionPlan, oldText: "# old", newText: "" }], |
| 2016 | }, |
| 2017 | linuxHost, |
| 2018 | ), |
| 2019 | ).toBe(""); |
| 2020 | |
| 2021 | expect( |
| 2022 | extractGrokPlanMarkdownFromToolCallData( |
| 2023 | { |
| 2024 | rawInput: { file_path: "/tmp/readme.md", content: "nope" }, |
| 2025 | }, |
| 2026 | linuxHost, |
| 2027 | ), |
| 2028 | ).toBeUndefined(); |
| 2029 | |
| 2030 | expect( |
| 2031 | extractGrokPlanMarkdownFromToolCallData( |
| 2032 | { |
| 2033 | rawInput: { file_path: "/repo/docs/plan.md", content: "# Project plan\n" }, |
| 2034 | }, |
| 2035 | linuxHost, |
| 2036 | ), |
| 2037 | ).toBeUndefined(); |
| 2038 | }); |
| 2039 | }); |