| author | |
| committer | |
| log | 52b04b947e3604e426386be53e6d20c6a4366fef |
| tree | fa346512aafa42b1ec4768f42d54d5e0e505273d |
| parent | e9ed70c5b935d8653c887c6a2f5c1f93013579d4 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius0216@outlook.com>
Co-authored-by: codex <codex@users.noreply.github.com>20 files changed, 2802 insertions(+), 133 deletions(-)
apps/server/scripts/acp-mock-agent.ts+260-13| ... | @@ -19,6 +19,23 @@ const emitInterleavedAssistantToolCalls = | ... | @@ -19,6 +19,23 @@ const emitInterleavedAssistantToolCalls = |
| 19 | const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; | 19 | const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1"; |
| 20 | const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; | 20 | const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1"; |
| 21 | const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; | 21 | const emitXAiAskUserQuestion = process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION === "1"; |
| 22 | const emitXAiPromptCompleteThenHang = process.env.T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG === "1"; | ||
| 23 | const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; | ||
| 24 | const hangPromptForever = process.env.T3_ACP_HANG_PROMPT_FOREVER === "1"; | ||
| 25 | const hangFirstPromptForever = process.env.T3_ACP_HANG_FIRST_PROMPT_FOREVER === "1"; | ||
| 26 | const emitLateUpdateAfterCancel = process.env.T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL === "1"; | ||
| 27 | const omitXAiPromptCompleteStopReason = | ||
| 28 | process.env.T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON === "1"; | ||
| 29 | const failLoadSession = process.env.T3_ACP_FAIL_LOAD_SESSION === "1"; | ||
| 30 | const emitLoadReplay = process.env.T3_ACP_EMIT_LOAD_REPLAY === "1"; | ||
| 31 | const hangLoadSessionAfterReplay = process.env.T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY === "1"; | ||
| 32 | const delayLoadSessionAfterReplay = process.env.T3_ACP_DELAY_LOAD_SESSION_AFTER_REPLAY === "1"; | ||
| 33 | const loadSessionDelayMs = Number(process.env.T3_ACP_LOAD_SESSION_DELAY_MS ?? "5000"); | ||
| 34 | const emitStaleXAiPromptCompleteBeforeSecondHang = | ||
| 35 | process.env.T3_ACP_EMIT_STALE_XAI_PROMPT_COMPLETE_BEFORE_SECOND_HANG === "1"; | ||
| 36 | const emitOverlappingXAiPromptCompleteOutOfOrder = | ||
| 37 | process.env.T3_ACP_EMIT_OVERLAPPING_XAI_PROMPT_COMPLETE_OUT_OF_ORDER === "1"; | ||
| 38 | const failPrompt = process.env.T3_ACP_FAIL_PROMPT === "1"; | ||
| 22 | const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; | 39 | const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1"; |
| 23 | const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; | 40 | const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1"; |
| 24 | const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; | 41 | const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT; |
| ... | @@ -36,8 +53,21 @@ let parameterizedModelPicker = false; | ... | @@ -36,8 +53,21 @@ let parameterizedModelPicker = false; |
| 36 | let currentReasoning = "medium"; | 53 | let currentReasoning = "medium"; |
| 37 | let currentContext = "272k"; | 54 | let currentContext = "272k"; |
| 38 | let currentFast = false; | 55 | let currentFast = false; |
| 56 | let promptCount = 0; | ||
| 57 | let overlappingFirstPromptId: string | undefined; | ||
| 39 | const cancelledSessions = new Set<string>(); | 58 | const cancelledSessions = new Set<string>(); |
| 40 | 59 | ||
| 60 | function promptIdFromRequestMeta( | ||
| 61 | request: Pick<AcpSchema.PromptRequest, "_meta">, | ||
| 62 | ): string | undefined { | ||
| 63 | const meta = request._meta; | ||
| 64 | if (meta === null || typeof meta !== "object") { | ||
| 65 | return undefined; | ||
| 66 | } | ||
| 67 | const promptId = meta.promptId ?? meta.requestId; | ||
| 68 | return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined; | ||
| 69 | } | ||
| 70 | |||
| 41 | function logExit(reason: string): void { | 71 | function logExit(reason: string): void { |
| 42 | if (!exitLogPath) { | 72 | if (!exitLogPath) { |
| 43 | return; | 73 | return; |
| ... | @@ -45,6 +75,10 @@ function logExit(reason: string): void { | ... | @@ -45,6 +75,10 @@ function logExit(reason: string): void { |
| 45 | NodeFS.appendFileSync(exitLogPath, `${reason}\n`, "utf8"); | 75 | NodeFS.appendFileSync(exitLogPath, `${reason}\n`, "utf8"); |
| 46 | } | 76 | } |
| 47 | 77 | ||
| 78 | function writeJsonRpcNotification(method: string, params: unknown): void { | ||
| 79 | process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`); | ||
| 80 | } | ||
| 81 | |||
| 48 | process.once("SIGTERM", () => { | 82 | process.once("SIGTERM", () => { |
| 49 | logExit("SIGTERM"); | 83 | logExit("SIGTERM"); |
| 50 | process.exit(0); | 84 | process.exit(0); |
| ... | @@ -284,22 +318,66 @@ const program = Effect.gen(function* () { | ... | @@ -284,22 +318,66 @@ const program = Effect.gen(function* () { |
| 284 | }), | 318 | }), |
| 285 | ); | 319 | ); |
| 286 | 320 | ||
| 321 | const emitLoadReplayNotifications = (requestedSessionId: string) => { | ||
| 322 | writeJsonRpcNotification("session/update", { | ||
| 323 | _meta: { isReplay: true }, | ||
| 324 | sessionId: requestedSessionId, | ||
| 325 | update: { | ||
| 326 | sessionUpdate: "tool_call", | ||
| 327 | toolCallId: "replay-tool-1", | ||
| 328 | title: "Replay tool", | ||
| 329 | kind: "search", | ||
| 330 | status: "completed", | ||
| 331 | }, | ||
| 332 | }); | ||
| 333 | writeJsonRpcNotification("session/update", { | ||
| 334 | _meta: { isReplay: true }, | ||
| 335 | sessionId: requestedSessionId, | ||
| 336 | update: { | ||
| 337 | sessionUpdate: "agent_message_chunk", | ||
| 338 | content: { type: "text", text: "replayed assistant text" }, | ||
| 339 | }, | ||
| 340 | }); | ||
| 341 | }; | ||
| 342 | |||
| 287 | yield* agent.handleLoadSession((request) => | 343 | yield* agent.handleLoadSession((request) => |
| 288 | agent.client | 344 | Effect.gen(function* () { |
| 289 | .sessionUpdate({ | 345 | const requestedSessionId = String(request.sessionId ?? sessionId); |
| 290 | sessionId: String(request.sessionId ?? sessionId), | 346 | if (failLoadSession) { |
| 347 | return yield* AcpError.AcpRequestError.internalError("Mock load session failure"); | ||
| 348 | } | ||
| 349 | if (hangLoadSessionAfterReplay || delayLoadSessionAfterReplay) { | ||
| 350 | emitLoadReplayNotifications(requestedSessionId); | ||
| 351 | yield* agent.client.sessionUpdate({ | ||
| 352 | sessionId: requestedSessionId, | ||
| 353 | update: { | ||
| 354 | sessionUpdate: "user_message_chunk", | ||
| 355 | content: { type: "text", text: "replay-tail" }, | ||
| 356 | }, | ||
| 357 | }); | ||
| 358 | yield* Effect.sleep(loadSessionDelayMs); | ||
| 359 | return { | ||
| 360 | modes: modeState(), | ||
| 361 | models: modelState(), | ||
| 362 | configOptions: configOptions(), | ||
| 363 | }; | ||
| 364 | } | ||
| 365 | if (emitLoadReplay) { | ||
| 366 | emitLoadReplayNotifications(requestedSessionId); | ||
| 367 | } | ||
| 368 | yield* agent.client.sessionUpdate({ | ||
| 369 | sessionId: requestedSessionId, | ||
| 291 | update: { | 370 | update: { |
| 292 | sessionUpdate: "user_message_chunk", | 371 | sessionUpdate: "user_message_chunk", |
| 293 | content: { type: "text", text: "replay" }, | 372 | content: { type: "text", text: "replay" }, |
| 294 | }, | 373 | }, |
| 295 | }) | 374 | }); |
| 296 | .pipe( | 375 | return { |
| 297 | Effect.as({ | 376 | modes: modeState(), |
| 298 | modes: modeState(), | 377 | models: modelState(), |
| 299 | models: modelState(), | 378 | configOptions: configOptions(), |
| 300 | configOptions: configOptions(), | 379 | }; |
| 301 | }), | 380 | }), |
| 302 | ), | ||
| 303 | ); | 381 | ); |
| 304 | 382 | ||
| 305 | yield* agent.handleSetSessionModel((request) => | 383 | yield* agent.handleSetSessionModel((request) => |
| ... | @@ -356,19 +434,152 @@ const program = Effect.gen(function* () { | ... | @@ -356,19 +434,152 @@ const program = Effect.gen(function* () { |
| 356 | ); | 434 | ); |
| 357 | 435 | ||
| 358 | yield* agent.handleCancel(({ sessionId }) => | 436 | yield* agent.handleCancel(({ sessionId }) => |
| 359 | Effect.sync(() => { | 437 | Effect.gen(function* () { |
| 360 | cancelledSessions.add(String(sessionId ?? "mock-session-1")); | 438 | const cancelledSessionId = String(sessionId ?? "mock-session-1"); |
| 439 | cancelledSessions.add(cancelledSessionId); | ||
| 440 | if (emitLateUpdateAfterCancel) { | ||
| 441 | yield* Effect.sleep("50 millis"); | ||
| 442 | yield* Effect.sync(() => { | ||
| 443 | writeJsonRpcNotification("session/update", { | ||
| 444 | sessionId: cancelledSessionId, | ||
| 445 | update: { | ||
| 446 | sessionUpdate: "agent_message_chunk", | ||
| 447 | content: { type: "text", text: "late after cancel" }, | ||
| 448 | }, | ||
| 449 | }); | ||
| 450 | }); | ||
| 451 | } | ||
| 361 | }), | 452 | }), |
| 362 | ); | 453 | ); |
| 363 | 454 | ||
| 364 | yield* agent.handlePrompt((request) => | 455 | yield* agent.handlePrompt((request) => |
| 365 | Effect.gen(function* () { | 456 | Effect.gen(function* () { |
| 366 | const requestedSessionId = String(request.sessionId ?? sessionId); | 457 | const requestedSessionId = String(request.sessionId ?? sessionId); |
| 458 | promptCount += 1; | ||
| 367 | 459 | ||
| 368 | if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) { | 460 | if (Number.isFinite(promptDelayMs) && promptDelayMs > 0) { |
| 369 | yield* Effect.sleep(`${promptDelayMs} millis`); | 461 | yield* Effect.sleep(`${promptDelayMs} millis`); |
| 370 | } | 462 | } |
| 371 | 463 | ||
| 464 | if (failPrompt) { | ||
| 465 | return yield* AcpError.AcpRequestError.internalError("Mock prompt failure"); | ||
| 466 | } | ||
| 467 | |||
| 468 | if (emitStaleXAiPromptCompleteBeforeSecondHang && promptCount === 1) { | ||
| 469 | return { | ||
| 470 | stopReason: "end_turn", | ||
| 471 | _meta: { | ||
| 472 | promptId: "mock-stale-xai-prompt-1", | ||
| 473 | requestId: "mock-stale-xai-prompt-1", | ||
| 474 | }, | ||
| 475 | }; | ||
| 476 | } | ||
| 477 | |||
| 478 | if (emitStaleXAiPromptCompleteBeforeSecondHang && promptCount === 2) { | ||
| 479 | const currentPromptId = promptIdFromRequestMeta(request) ?? "mock-current-xai-prompt-2"; | ||
| 480 | writeJsonRpcNotification("_x.ai/session/prompt_complete", { | ||
| 481 | sessionId: requestedSessionId, | ||
| 482 | promptId: "mock-stale-xai-prompt-1", | ||
| 483 | stopReason: "end_turn", | ||
| 484 | agentResult: null, | ||
| 485 | }); | ||
| 486 | |||
| 487 | writeJsonRpcNotification("_x.ai/session/prompt_complete", { | ||
| 488 | sessionId: requestedSessionId, | ||
| 489 | promptId: currentPromptId, | ||
| 490 | stopReason: "end_turn", | ||
| 491 | agentResult: null, | ||
| 492 | }); | ||
| 493 | |||
| 494 | return yield* Effect.never; | ||
| 495 | } | ||
| 496 | |||
| 497 | if (emitOverlappingXAiPromptCompleteOutOfOrder && promptCount === 1) { | ||
| 498 | overlappingFirstPromptId = promptIdFromRequestMeta(request); | ||
| 499 | return yield* Effect.never; | ||
| 500 | } | ||
| 501 | |||
| 502 | if (emitOverlappingXAiPromptCompleteOutOfOrder && promptCount === 2) { | ||
| 503 | const secondPromptId = promptIdFromRequestMeta(request); | ||
| 504 | if (overlappingFirstPromptId !== undefined && secondPromptId !== undefined) { | ||
| 505 | writeJsonRpcNotification("_x.ai/session/prompt_complete", { | ||
| 506 | sessionId: requestedSessionId, | ||
| 507 | promptId: secondPromptId, | ||
| 508 | stopReason: "end_turn", | ||
| 509 | agentResult: null, | ||
| 510 | }); | ||
| 511 | writeJsonRpcNotification("_x.ai/session/prompt_complete", { | ||
| 512 | sessionId: requestedSessionId, | ||
| 513 | promptId: overlappingFirstPromptId, | ||
| 514 | stopReason: "end_turn", | ||
| 515 | agentResult: null, | ||
| 516 | }); | ||
| 517 | } | ||
| 518 | return yield* Effect.never; | ||
| 519 | } | ||
| 520 | |||
| 521 | if (hangPromptForever || (hangFirstPromptForever && promptCount === 1)) { | ||
| 522 | return yield* Effect.never; | ||
| 523 | } | ||
| 524 | |||
| 525 | if (emitXAiPromptCompleteThenHang) { | ||
| 526 | writeJsonRpcNotification("session/update", { | ||
| 527 | sessionId: requestedSessionId, | ||
| 528 | update: { | ||
| 529 | sessionUpdate: "agent_message_chunk", | ||
| 530 | content: { type: "text", text: "hello from " }, | ||
| 531 | }, | ||
| 532 | }); | ||
| 533 | |||
| 534 | if (emitForeignSessionUpdates) { | ||
| 535 | writeJsonRpcNotification("session/update", { | ||
| 536 | sessionId: "mock-child-session-1", | ||
| 537 | update: { | ||
| 538 | sessionUpdate: "agent_message_chunk", | ||
| 539 | content: { type: "text", text: "child before completion" }, | ||
| 540 | }, | ||
| 541 | }); | ||
| 542 | } | ||
| 543 | |||
| 544 | writeJsonRpcNotification("_x.ai/session/prompt_complete", { | ||
| 545 | sessionId: requestedSessionId, | ||
| 546 | promptId: promptIdFromRequestMeta(request) ?? "mock-xai-prompt-1", | ||
| 547 | ...(omitXAiPromptCompleteStopReason ? {} : { stopReason: "end_turn" }), | ||
| 548 | agentResult: null, | ||
| 549 | }); | ||
| 550 | |||
| 551 | if (emitForeignSessionUpdates) { | ||
| 552 | writeJsonRpcNotification("session/update", { | ||
| 553 | sessionId: "mock-child-session-1", | ||
| 554 | update: { | ||
| 555 | sessionUpdate: "tool_call", | ||
| 556 | toolCallId: "child-tool-call-1", | ||
| 557 | title: "Child-only tool", | ||
| 558 | kind: "other", | ||
| 559 | status: "pending", | ||
| 560 | rawInput: {}, | ||
| 561 | }, | ||
| 562 | }); | ||
| 563 | writeJsonRpcNotification("session/update", { | ||
| 564 | sessionId: "mock-child-session-1", | ||
| 565 | update: { | ||
| 566 | sessionUpdate: "agent_message_chunk", | ||
| 567 | content: { type: "text", text: "child after completion" }, | ||
| 568 | }, | ||
| 569 | }); | ||
| 570 | } | ||
| 571 | |||
| 572 | writeJsonRpcNotification("session/update", { | ||
| 573 | sessionId: requestedSessionId, | ||
| 574 | update: { | ||
| 575 | sessionUpdate: "agent_message_chunk", | ||
| 576 | content: { type: "text", text: "mock" }, | ||
| 577 | }, | ||
| 578 | }); | ||
| 579 | |||
| 580 | return yield* Effect.never; | ||
| 581 | } | ||
| 582 | |||
| 372 | if (emitInterleavedAssistantToolCalls) { | 583 | if (emitInterleavedAssistantToolCalls) { |
| 373 | const toolCallId = "tool-call-1"; | 584 | const toolCallId = "tool-call-1"; |
| 374 | 585 | ||
| ... | @@ -599,6 +810,42 @@ const program = Effect.gen(function* () { | ... | @@ -599,6 +810,42 @@ const program = Effect.gen(function* () { |
| 599 | return { stopReason: "end_turn" }; | 810 | return { stopReason: "end_turn" }; |
| 600 | } | 811 | } |
| 601 | 812 | ||
| 813 | if (emitForeignSessionUpdates) { | ||
| 814 | yield* agent.client.sessionUpdate({ | ||
| 815 | sessionId: requestedSessionId, | ||
| 816 | update: { | ||
| 817 | sessionUpdate: "agent_message_chunk", | ||
| 818 | content: { type: "text", text: "root before child" }, | ||
| 819 | }, | ||
| 820 | }); | ||
| 821 | yield* agent.client.sessionUpdate({ | ||
| 822 | sessionId: "mock-child-session-1", | ||
| 823 | update: { | ||
| 824 | sessionUpdate: "agent_message_chunk", | ||
| 825 | content: { type: "text", text: "child content" }, | ||
| 826 | }, | ||
| 827 | }); | ||
| 828 | yield* agent.client.sessionUpdate({ | ||
| 829 | sessionId: "mock-child-session-1", | ||
| 830 | update: { | ||
| 831 | sessionUpdate: "tool_call", | ||
| 832 | toolCallId: "child-tool-call-1", | ||
| 833 | title: "Child-only tool", | ||
| 834 | kind: "other", | ||
| 835 | status: "pending", | ||
| 836 | rawInput: {}, | ||
| 837 | }, | ||
| 838 | }); | ||
| 839 | yield* agent.client.sessionUpdate({ | ||
| 840 | sessionId: requestedSessionId, | ||
| 841 | update: { | ||
| 842 | sessionUpdate: "agent_message_chunk", | ||
| 843 | content: { type: "text", text: " root after child" }, | ||
| 844 | }, | ||
| 845 | }); | ||
| 846 | return { stopReason: "end_turn" }; | ||
| 847 | } | ||
| 848 | |||
| 602 | yield* agent.client.sessionUpdate({ | 849 | yield* agent.client.sessionUpdate({ |
| 603 | sessionId: requestedSessionId, | 850 | sessionId: requestedSessionId, |
| 604 | update: { | 851 | update: { |
apps/server/src/provider/Layers/CursorAdapter.test.ts+21-1| ... | @@ -113,6 +113,23 @@ async function waitForFileContent(filePath: string, attempts = 40) { | ... | @@ -113,6 +113,23 @@ async function waitForFileContent(filePath: string, attempts = 40) { |
| 113 | throw new Error(`Timed out waiting for file content at ${filePath}`); | 113 | throw new Error(`Timed out waiting for file content at ${filePath}`); |
| 114 | } | 114 | } |
| 115 | 115 | ||
| 116 | function waitForJsonLogMatch( | ||
| 117 | filePath: string, | ||
| 118 | predicate: (entry: Record<string, unknown>) => boolean, | ||
| 119 | attempts = 40, | ||
| 120 | ) { | ||
| 121 | return Effect.gen(function* () { | ||
| 122 | for (let attempt = 0; attempt < attempts; attempt += 1) { | ||
| 123 | const requests = yield* Effect.promise(() => readJsonLines(filePath)); | ||
| 124 | if (requests.some(predicate)) { | ||
| 125 | return requests; | ||
| 126 | } | ||
| 127 | yield* Effect.yieldNow; | ||
| 128 | } | ||
| 129 | return yield* Effect.promise(() => readJsonLines(filePath)); | ||
| 130 | }); | ||
| 131 | } | ||
| 132 | |||
| 116 | // Tests mutate `ServerSettingsService` mid-flight (e.g. setting | 133 | // Tests mutate `ServerSettingsService` mid-flight (e.g. setting |
| 117 | // `providers.cursor.binaryPath` to a mock ACP wrapper). The adapter | 134 | // `providers.cursor.binaryPath` to a mock ACP wrapper). The adapter |
| 118 | // captures `cursorSettings` once at construction, so without a resolver | 135 | // captures `cursorSettings` once at construction, so without a resolver |
| ... | @@ -1004,7 +1021,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { | ... | @@ -1004,7 +1021,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { |
| 1004 | assert.equal(turnCompleted.payload.stopReason, "cancelled"); | 1021 | assert.equal(turnCompleted.payload.stopReason, "cancelled"); |
| 1005 | } | 1022 | } |
| 1006 | 1023 | ||
| 1007 | const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); | 1024 | const requests = yield* waitForJsonLogMatch( |
| 1025 | requestLogPath, | ||
| 1026 | (entry) => entry.method === "session/cancel", | ||
| 1027 | ); | ||
| 1008 | assert.isTrue(requests.some((entry) => entry.method === "session/cancel")); | 1028 | assert.isTrue(requests.some((entry) => entry.method === "session/cancel")); |
| 1009 | assert.isTrue( | 1029 | assert.isTrue( |
| 1010 | requests.some( | 1030 | requests.some( |
apps/server/src/provider/Layers/CursorAdapter.ts+3| ... | @@ -785,6 +785,9 @@ export function makeCursorAdapter( | ... | @@ -785,6 +785,9 @@ export function makeCursorAdapter( |
| 785 | Stream.mapEffect(acp.getEvents(), (event) => | 785 | Stream.mapEffect(acp.getEvents(), (event) => |
| 786 | Effect.gen(function* () { | 786 | Effect.gen(function* () { |
| 787 | switch (event._tag) { | 787 | switch (event._tag) { |
| 788 | case "EventStreamBarrier": | ||
| 789 | yield* Deferred.succeed(event.acknowledge, undefined); | ||
| 790 | return; | ||
| 788 | case "ModeChanged": | 791 | case "ModeChanged": |
| 789 | return; | 792 | return; |
| 790 | case "AssistantItemStarted": | 793 | case "AssistantItemStarted": |
apps/server/src/provider/Layers/GrokAdapter.test.ts+825-4| ... | @@ -10,20 +10,23 @@ import * as Deferred from "effect/Deferred"; | ... | @@ -10,20 +10,23 @@ import * as Deferred from "effect/Deferred"; |
| 10 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Fiber from "effect/Fiber"; | 11 | import * as Fiber from "effect/Fiber"; |
| 12 | import * as Layer from "effect/Layer"; | 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as Ref from "effect/Ref"; | ||
| 13 | import * as Schema from "effect/Schema"; | 14 | import * as Schema from "effect/Schema"; |
| 14 | import * as Stream from "effect/Stream"; | 15 | import * as Stream from "effect/Stream"; |
| 16 | import * as TestClock from "effect/testing/TestClock"; | ||
| 15 | 17 | ||
| 16 | import { | 18 | import { |
| 17 | ApprovalRequestId, | 19 | ApprovalRequestId, |
| 18 | GrokSettings, | 20 | GrokSettings, |
| 19 | ProviderDriverKind, | 21 | ProviderDriverKind, |
| 20 | ThreadId, | ||
| 21 | ProviderInstanceId, | 22 | ProviderInstanceId, |
| 23 | ThreadId, | ||
| 24 | TurnId, | ||
| 22 | type ProviderRuntimeEvent, | 25 | type ProviderRuntimeEvent, |
| 23 | } from "@t3tools/contracts"; | 26 | } from "@t3tools/contracts"; |
| 24 | 27 | ||
| 25 | import { ServerConfig } from "../../config.ts"; | 28 | import { ServerConfig } from "../../config.ts"; |
| 26 | import { makeGrokAdapter } from "./GrokAdapter.ts"; | 29 | import { grokPromptSettlementBelongsToContext, makeGrokAdapter } from "./GrokAdapter.ts"; |
| 27 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | 30 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 28 | 31 | ||
| 29 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | 32 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
| ... | @@ -45,7 +48,11 @@ exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" | ... | @@ -45,7 +48,11 @@ exec ${JSON.stringify(mockAgentCommand)} ${JSON.stringify(mockAgentPath)} "$@" |
| 45 | return wrapperPath; | 48 | return wrapperPath; |
| 46 | } | 49 | } |
| 47 | 50 | ||
| 48 | function waitForFileContent(filePath: string, attempts = 40): Effect.Effect<string> { | 51 | function waitForFileContent( |
| 52 | filePath: string, | ||
| 53 | attempts = 40, | ||
| 54 | expectedContent?: string, | ||
| 55 | ): Effect.Effect<string> { | ||
| 49 | const readAttempt = (remainingAttempts: number): Effect.Effect<string> => | 56 | const readAttempt = (remainingAttempts: number): Effect.Effect<string> => |
| 50 | Effect.gen(function* () { | 57 | Effect.gen(function* () { |
| 51 | if (remainingAttempts <= 0) { | 58 | if (remainingAttempts <= 0) { |
| ... | @@ -54,7 +61,10 @@ function waitForFileContent(filePath: string, attempts = 40): Effect.Effect<stri | ... | @@ -54,7 +61,10 @@ function waitForFileContent(filePath: string, attempts = 40): Effect.Effect<stri |
| 54 | const raw = yield* Effect.tryPromise(() => NodeFSP.readFile(filePath, "utf8")).pipe( | 61 | const raw = yield* Effect.tryPromise(() => NodeFSP.readFile(filePath, "utf8")).pipe( |
| 55 | Effect.orElseSucceed(() => ""), | 62 | Effect.orElseSucceed(() => ""), |
| 56 | ); | 63 | ); |
| 57 | if (raw.trim().length > 0) { | 64 | if ( |
| 65 | raw.trim().length > 0 && | ||
| 66 | (expectedContent === undefined || raw.includes(expectedContent)) | ||
| 67 | ) { | ||
| 58 | return raw; | 68 | return raw; |
| 59 | } | 69 | } |
| 60 | yield* Effect.sleep("25 millis"); | 70 | yield* Effect.sleep("25 millis"); |
| ... | @@ -79,6 +89,39 @@ const grokAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { | ... | @@ -79,6 +89,39 @@ const grokAdapterTestLayer = ServerConfig.layerTest(process.cwd(), { |
| 79 | const makeTestAdapter = (binaryPath: string, options?: Parameters<typeof makeGrokAdapter>[1]) => | 89 | const makeTestAdapter = (binaryPath: string, options?: Parameters<typeof makeGrokAdapter>[1]) => |
| 80 | makeGrokAdapter(decodeGrokSettings({ binaryPath }), options).pipe(Effect.orDie); | 90 | makeGrokAdapter(decodeGrokSettings({ binaryPath }), options).pipe(Effect.orDie); |
| 81 | 91 | ||
| 92 | it("requires a settlement to match the live Grok turn", () => { | ||
| 93 | const staleTurnId = TurnId.make("stale-turn"); | ||
| 94 | const replacementTurnId = TurnId.make("replacement-turn"); | ||
| 95 | |||
| 96 | assert.isFalse( | ||
| 97 | grokPromptSettlementBelongsToContext({ | ||
| 98 | liveAcpSessionId: "session-1", | ||
| 99 | expectedAcpSessionId: "session-1", | ||
| 100 | liveActiveTurnId: replacementTurnId, | ||
| 101 | liveSessionActiveTurnId: replacementTurnId, | ||
| 102 | turnId: staleTurnId, | ||
| 103 | }), | ||
| 104 | ); | ||
| 105 | assert.isFalse( | ||
| 106 | grokPromptSettlementBelongsToContext({ | ||
| 107 | liveAcpSessionId: "replacement-session", | ||
| 108 | expectedAcpSessionId: "stale-session", | ||
| 109 | liveActiveTurnId: staleTurnId, | ||
| 110 | liveSessionActiveTurnId: staleTurnId, | ||
| 111 | turnId: staleTurnId, | ||
| 112 | }), | ||
| 113 | ); | ||
| 114 | assert.isTrue( | ||
| 115 | grokPromptSettlementBelongsToContext({ | ||
| 116 | liveAcpSessionId: "session-1", | ||
| 117 | expectedAcpSessionId: "session-1", | ||
| 118 | liveActiveTurnId: staleTurnId, | ||
| 119 | liveSessionActiveTurnId: staleTurnId, | ||
| 120 | turnId: staleTurnId, | ||
| 121 | }), | ||
| 122 | ); | ||
| 123 | }); | ||
| 124 | |||
| 82 | it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { | 125 | it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { |
| 83 | it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => | 126 | it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => |
| 84 | Effect.gen(function* () { | 127 | Effect.gen(function* () { |
| ... | @@ -175,6 +218,783 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { | ... | @@ -175,6 +218,783 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { |
| 175 | }), | 218 | }), |
| 176 | ); | 219 | ); |
| 177 | 220 | ||
| 221 | it.effect("reports a Grok session running only while the prompt is in flight", () => | ||
| 222 | Effect.gen(function* () { | ||
| 223 | const threadId = ThreadId.make("grok-session-ready-after-prompt"); | ||
| 224 | const wrapperPath = yield* Effect.promise(() => | ||
| 225 | makeMockGrokWrapper({ | ||
| 226 | T3_ACP_EMIT_TOOL_CALLS: "1", | ||
| 227 | }), | ||
| 228 | ); | ||
| 229 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 230 | const requestOpened = | ||
| 231 | yield* Deferred.make<Extract<ProviderRuntimeEvent, { type: "request.opened" }>>(); | ||
| 232 | const eventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => | ||
| 233 | event.type === "request.opened" | ||
| 234 | ? Deferred.succeed(requestOpened, event).pipe(Effect.ignore) | ||
| 235 | : Effect.void, | ||
| 236 | ).pipe(Effect.forkChild); | ||
| 237 | |||
| 238 | yield* adapter.startSession({ | ||
| 239 | threadId, | ||
| 240 | provider: ProviderDriverKind.make("grok"), | ||
| 241 | cwd: process.cwd(), | ||
| 242 | runtimeMode: "approval-required", | ||
| 243 | modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, | ||
| 244 | }); | ||
| 245 | |||
| 246 | const sendTurnFiber = yield* adapter | ||
| 247 | .sendTurn({ threadId, input: "check lifecycle", attachments: [] }) | ||
| 248 | .pipe(Effect.forkChild); | ||
| 249 | const requestOpenedEvent = yield* Deferred.await(requestOpened); | ||
| 250 | |||
| 251 | const runningSessions = yield* adapter.listSessions(); | ||
| 252 | const runningSession = runningSessions.find((session) => session.threadId === threadId); | ||
| 253 | assert.equal(runningSession?.status, "running"); | ||
| 254 | assert.isDefined(runningSession?.activeTurnId); | ||
| 255 | |||
| 256 | yield* adapter.respondToRequest( | ||
| 257 | threadId, | ||
| 258 | ApprovalRequestId.make(String(requestOpenedEvent.requestId)), | ||
| 259 | "accept", | ||
| 260 | ); | ||
| 261 | yield* Fiber.join(sendTurnFiber); | ||
| 262 | |||
| 263 | const readySessions = yield* adapter.listSessions(); | ||
| 264 | const readySession = readySessions.find((session) => session.threadId === threadId); | ||
| 265 | assert.equal(readySession?.status, "ready"); | ||
| 266 | assert.isUndefined(readySession?.activeTurnId); | ||
| 267 | |||
| 268 | yield* Fiber.interrupt(eventsFiber); | ||
| 269 | yield* adapter.stopSession(threadId); | ||
| 270 | }), | ||
| 271 | ); | ||
| 272 | |||
| 273 | it.effect("restores ready without completing an unstarted turn when preparation fails", () => | ||
| 274 | Effect.gen(function* () { | ||
| 275 | const threadId = ThreadId.make("grok-preparation-failure-while-connecting"); | ||
| 276 | const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); | ||
| 277 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 278 | |||
| 279 | const runtimeEvents: ProviderRuntimeEvent[] = []; | ||
| 280 | const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => | ||
| 281 | Effect.sync(() => { | ||
| 282 | runtimeEvents.push(event); | ||
| 283 | }), | ||
| 284 | ).pipe(Effect.forkChild); | ||
| 285 | |||
| 286 | yield* adapter.startSession({ | ||
| 287 | threadId, | ||
| 288 | provider: ProviderDriverKind.make("grok"), | ||
| 289 | cwd: process.cwd(), | ||
| 290 | runtimeMode: "full-access", | ||
| 291 | modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, | ||
| 292 | }); | ||
| 293 | |||
| 294 | const error = yield* Effect.flip( | ||
| 295 | adapter.sendTurn({ | ||
| 296 | threadId, | ||
| 297 | input: "prepare invalid attachment", | ||
| 298 | attachments: [ | ||
| 299 | { | ||
| 300 | type: "image", | ||
| 301 | id: "missing-image", | ||
| 302 | name: "missing.png", | ||
| 303 | mimeType: "image/png", | ||
| 304 | sizeBytes: 1, | ||
| 305 | }, | ||
| 306 | ], | ||
| 307 | }), | ||
| 308 | ); | ||
| 309 | for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { | ||
| 310 | yield* Effect.yieldNow; | ||
| 311 | } | ||
| 312 | |||
| 313 | const turnCompletedEvent = runtimeEvents.find( | ||
| 314 | (event): event is Extract<ProviderRuntimeEvent, { type: "turn.completed" }> => | ||
| 315 | event.type === "turn.completed", | ||
| 316 | ); | ||
| 317 | const readySessions = yield* adapter.listSessions(); | ||
| 318 | const readySession = readySessions.find((session) => session.threadId === threadId); | ||
| 319 | |||
| 320 | assert.equal(error._tag, "ProviderAdapterRequestError"); | ||
| 321 | assert.isUndefined(turnCompletedEvent); | ||
| 322 | assert.equal(readySession?.status, "ready"); | ||
| 323 | assert.isUndefined(readySession?.activeTurnId); | ||
| 324 | |||
| 325 | yield* Fiber.interrupt(runtimeEventsFiber); | ||
| 326 | yield* adapter.stopSession(threadId); | ||
| 327 | }), | ||
| 328 | ); | ||
| 329 | |||
| 330 | it.effect("completes a Grok turn from xAI prompt completion when the prompt RPC hangs", () => | ||
| 331 | Effect.gen(function* () { | ||
| 332 | const threadId = ThreadId.make("grok-xai-prompt-complete-fallback"); | ||
| 333 | const wrapperPath = yield* Effect.promise(() => | ||
| 334 | makeMockGrokWrapper({ | ||
| 335 | T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", | ||
| 336 | T3_ACP_EMIT_FOREIGN_SESSION_UPDATES: "1", | ||
| 337 | }), | ||
| 338 | ); | ||
| 339 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 340 | |||
| 341 | const runtimeEvents: ProviderRuntimeEvent[] = []; | ||
| 342 | const turnCompleted = yield* Deferred.make<void>(); | ||
| 343 | const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => | ||
| 344 | Effect.sync(() => { | ||
| 345 | runtimeEvents.push(event); | ||
| 346 | }).pipe( | ||
| 347 | Effect.andThen( | ||
| 348 | event.type === "turn.completed" | ||
| 349 | ? Deferred.succeed(turnCompleted, undefined) | ||
| 350 | : Effect.void, | ||
| 351 | ), | ||
| 352 | ), | ||
| 353 | ).pipe(Effect.forkChild); | ||
| 354 | |||
| 355 | yield* adapter.startSession({ | ||
| 356 | threadId, | ||
| 357 | provider: ProviderDriverKind.make("grok"), | ||
| 358 | cwd: process.cwd(), | ||
| 359 | runtimeMode: "full-access", | ||
| 360 | modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, | ||
| 361 | }); | ||
| 362 | |||
| 363 | const sendTurnResult = yield* adapter.sendTurn({ | ||
| 364 | threadId, | ||
| 365 | input: "exercise fallback", | ||
| 366 | attachments: [], | ||
| 367 | }); | ||
| 368 | |||
| 369 | yield* Deferred.await(turnCompleted); | ||
| 370 | for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { | ||
| 371 | yield* Effect.yieldNow; | ||
| 372 | } | ||
| 373 | const readySessions = yield* adapter.listSessions(); | ||
| 374 | const readySession = readySessions.find((session) => session.threadId === threadId); | ||
| 375 | const turnCompletedEvent = runtimeEvents.find( | ||
| 376 | (event): event is Extract<ProviderRuntimeEvent, { type: "turn.completed" }> => | ||
| 377 | event.type === "turn.completed", | ||
| 378 | ); | ||
| 379 | const eventTypes = runtimeEvents.map((event) => event.type); | ||
| 380 | const content = runtimeEvents | ||
| 381 | .filter( | ||
| 382 | (event): event is Extract<ProviderRuntimeEvent, { type: "content.delta" }> => | ||
| 383 | event.type === "content.delta" && String(event.threadId) === String(threadId), | ||
| 384 | ) | ||
| 385 | .map((event) => event.payload.delta) | ||
| 386 | .join(""); | ||
| 387 | const terminalIndex = runtimeEvents.findIndex( | ||
| 388 | (event) => event.type === "turn.completed" && String(event.threadId) === String(threadId), | ||
| 389 | ); | ||
| 390 | const turnOutputTypes = new Set([ | ||
| 391 | "content.delta", | ||
| 392 | "item.started", | ||
| 393 | "item.updated", | ||
| 394 | "item.completed", | ||
| 395 | "turn.plan.updated", | ||
| 396 | ]); | ||
| 397 | const outputAfterTerminal = runtimeEvents | ||
| 398 | .slice(terminalIndex + 1) | ||
| 399 | .filter( | ||
| 400 | (event) => String(event.threadId) === String(threadId) && turnOutputTypes.has(event.type), | ||
| 401 | ); | ||
| 402 | const toolTitles = runtimeEvents.flatMap((event) => | ||
| 403 | event.type === "item.updated" && event.payload.title ? [event.payload.title] : [], | ||
| 404 | ); | ||
| 405 | |||
| 406 | assert.equal(sendTurnResult.threadId, threadId); | ||
| 407 | assert.include(eventTypes, "turn.completed"); | ||
| 408 | assert.equal(content, "hello from mock"); | ||
| 409 | assert.isAtLeast(terminalIndex, 0); | ||
| 410 | assert.deepEqual(outputAfterTerminal, []); | ||
| 411 | assert.notInclude(toolTitles, "Child-only tool"); | ||
| 412 | assert.equal(turnCompletedEvent?.payload.stopReason, "end_turn"); | ||
| 413 | assert.equal(readySession?.status, "ready"); | ||
| 414 | assert.isUndefined(readySession?.activeTurnId); | ||
| 415 | |||
| 416 | yield* Fiber.interrupt(runtimeEventsFiber); | ||
| 417 | yield* adapter.stopSession(threadId); | ||
| 418 | }), | ||
| 419 | ); | ||
| 420 | |||
| 421 | it.effect("retains turn transcript when sendTurn is interrupted after prompt success", () => | ||
| 422 | Effect.gen(function* () { | ||
| 423 | const threadId = ThreadId.make("grok-send-turn-interrupt-after-prompt"); | ||
| 424 | const wrapperPath = yield* Effect.promise(() => | ||
| 425 | makeMockGrokWrapper({ | ||
| 426 | T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", | ||
| 427 | }), | ||
| 428 | ); | ||
| 429 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 430 | const contentDelta = yield* Deferred.make<void>(); | ||
| 431 | const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => | ||
| 432 | event.type === "content.delta" ? Deferred.succeed(contentDelta, undefined) : Effect.void, | ||
| 433 | ).pipe(Effect.forkChild); | ||
| 434 | |||
| 435 | yield* adapter.startSession({ | ||
| 436 | threadId, | ||
| 437 | provider: ProviderDriverKind.make("grok"), | ||
| 438 | cwd: process.cwd(), | ||
| 439 | runtimeMode: "full-access", | ||
| 440 | modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, | ||
| 441 | }); | ||
| 442 | |||
| 443 | const sendTurnFiber = yield* adapter | ||
| 444 | .sendTurn({ | ||
| 445 | threadId, | ||
| 446 | input: "interrupt after prompt", | ||
| 447 | attachments: [], | ||
| 448 | }) | ||
| 449 | .pipe(Effect.forkChild); | ||
| 450 | |||
| 451 | yield* Deferred.await(contentDelta); | ||
| 452 | for (let yieldAttempt = 0; yieldAttempt < 6; yieldAttempt += 1) { | ||
| 453 | yield* Effect.yieldNow; | ||
| 454 | } | ||
| 455 | yield* Fiber.interrupt(sendTurnFiber); | ||
| 456 | for (let yieldAttempt = 0; yieldAttempt < 4; yieldAttempt += 1) { | ||
| 457 | yield* Effect.yieldNow; | ||
| 458 | } | ||
| 459 | |||
| 460 | const snapshot = yield* adapter.readThread(threadId); | ||
| 461 | assert.equal(snapshot.turns.length, 1); | ||
| 462 | assert.equal(snapshot.turns[0]?.items.length, 1); | ||
| 463 | |||
| 464 | yield* Fiber.interrupt(runtimeEventsFiber); | ||
| 465 | yield* adapter.stopSession(threadId); | ||
| 466 | }), | ||
| 467 | ); | ||
| 468 | |||
| 469 | it.effect("does not report a synthetic stop reason when xAI omits one", () => | ||
| 470 | Effect.gen(function* () { | ||
| 471 | const threadId = ThreadId.make("grok-xai-prompt-complete-missing-stop-reason"); | ||
| 472 | const wrapperPath = yield* Effect.promise(() => | ||
| 473 | makeMockGrokWrapper({ | ||
| 474 | T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", | ||
| 475 | T3_ACP_OMIT_XAI_PROMPT_COMPLETE_STOP_REASON: "1", | ||
| 476 | }), | ||
| 477 | ); | ||
| 478 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 479 | |||
| 480 | const runtimeEvents: ProviderRuntimeEvent[] = []; | ||
| 481 | const turnCompleted = yield* Deferred.make<void>(); | ||
| 482 | const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => | ||
| 483 | Effect.sync(() => { | ||
| 484 | runtimeEvents.push(event); | ||
| 485 | }).pipe( | ||
| 486 | Effect.andThen( | ||
| 487 | event.type === "turn.completed" | ||
| 488 | ? Deferred.succeed(turnCompleted, undefined) | ||
| 489 | : Effect.void, | ||
| 490 | ), | ||
| 491 | ), | ||
| 492 | ).pipe(Effect.forkChild); | ||
| 493 | |||
| 494 | yield* adapter.startSession({ | ||
| 495 | threadId, | ||
| 496 | provider: ProviderDriverKind.make("grok"), | ||
| 497 | cwd: process.cwd(), | ||
| 498 | runtimeMode: "full-access", | ||
| 499 | modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, | ||
| 500 | }); | ||
| 501 | |||
| 502 | yield* adapter.sendTurn({ | ||
| 503 | threadId, | ||
| 504 | input: "exercise missing stop reason", | ||
| 505 | attachments: [], | ||
| 506 | }); | ||
| 507 | |||
| 508 | yield* Deferred.await(turnCompleted); | ||
| 509 | const turnCompletedEvent = runtimeEvents.find( | ||
| 510 | (event): event is Extract<ProviderRuntimeEvent, { type: "turn.completed" }> => | ||
| 511 | event.type === "turn.completed", | ||
| 512 | ); | ||
| 513 | |||
| 514 | assert.equal(turnCompletedEvent?.payload.state, "completed"); | ||
| 515 | assert.isNull(turnCompletedEvent?.payload.stopReason); | ||
| 516 | |||
| 517 | yield* Fiber.interrupt(runtimeEventsFiber); | ||
| 518 | yield* adapter.stopSession(threadId); | ||
| 519 | }), | ||
| 520 | ); | ||
| 521 | |||
| 522 | it.effect("lets Stop unblock a fully silent Grok prompt and accept a follow-up turn", () => | ||
| 523 | Effect.gen(function* () { | ||
| 524 | const threadId = ThreadId.make("grok-stop-after-full-silence"); | ||
| 525 | const wrapperPath = yield* Effect.promise(() => | ||
| 526 | makeMockGrokWrapper({ | ||
| 527 | T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", | ||
| 528 | }), | ||
| 529 | ); | ||
| 530 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 531 | |||
| 532 | const runtimeEvents: ProviderRuntimeEvent[] = []; | ||
| 533 | const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => | ||
| 534 | Effect.sync(() => { | ||
| 535 | runtimeEvents.push(event); | ||
| 536 | }), | ||
| 537 | ).pipe(Effect.forkChild); | ||
| 538 | |||
| 539 | yield* adapter.startSession({ | ||
| 540 | threadId, | ||
| 541 | provider: ProviderDriverKind.make("grok"), | ||
| 542 | cwd: process.cwd(), | ||
| 543 | runtimeMode: "full-access", | ||
| 544 | modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, | ||
| 545 | }); | ||
| 546 | |||
| 547 | yield* Effect.gen(function* () { | ||
| 548 | yield* Effect.sleep("500 millis"); | ||
| 549 | yield* adapter.interruptTurn(threadId); | ||
| 550 | }).pipe(Effect.forkChild({ startImmediately: true })); | ||
| 551 | |||
| 552 | yield* adapter.sendTurn({ | ||
| 553 | threadId, | ||
| 554 | input: "hang forever", | ||
| 555 | attachments: [], | ||
| 556 | }); | ||
| 557 | for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { | ||
| 558 | yield* Effect.yieldNow; | ||
| 559 | } | ||
| 560 | |||
| 561 | const cancelledEvents = runtimeEvents.filter( | ||
| 562 | (event): event is Extract<ProviderRuntimeEvent, { type: "turn.completed" }> => | ||
| 563 | event.type === "turn.completed" && String(event.threadId) === String(threadId), | ||
| 564 | ); | ||
| 565 | const readySessions = yield* adapter.listSessions(); | ||
| 566 | const readySession = readySessions.find((session) => session.threadId === threadId); | ||
| 567 | |||
| 568 | assert.lengthOf(cancelledEvents, 1); | ||
| 569 | assert.equal(cancelledEvents[0]?.payload.state, "cancelled"); | ||
| 570 | assert.equal(readySession?.status, "ready"); | ||
| 571 | assert.isUndefined(readySession?.activeTurnId); | ||
| 572 | |||
| 573 | const followUpEventsBefore = runtimeEvents.length; | ||
| 574 | yield* adapter.sendTurn({ | ||
| 575 | threadId, | ||
| 576 | input: "continue after stop", | ||
| 577 | attachments: [], | ||
| 578 | }); | ||
| 579 | for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { | ||
| 580 | yield* Effect.yieldNow; | ||
| 581 | } | ||
| 582 | |||
| 583 | const followUpCompletedEvents = runtimeEvents | ||
| 584 | .slice(followUpEventsBefore) | ||
| 585 | .filter( | ||
| 586 | (event): event is Extract<ProviderRuntimeEvent, { type: "turn.completed" }> => | ||
| 587 | event.type === "turn.completed" && String(event.threadId) === String(threadId), | ||
| 588 | ); | ||
| 589 | assert.lengthOf(followUpCompletedEvents, 1); | ||
| 590 | assert.equal(followUpCompletedEvents[0]?.payload.state, "completed"); | ||
| 591 | |||
| 592 | yield* Fiber.interrupt(runtimeEventsFiber); | ||
| 593 | yield* adapter.stopSession(threadId); | ||
| 594 | }).pipe(TestClock.withLive), | ||
| 595 | ); | ||
| 596 | |||
| 597 | it.effect("does not let a cancelled prompt settlement consume the follow-up prompt slot", () => | ||
| 598 | Effect.gen(function* () { | ||
| 599 | const threadId = ThreadId.make("grok-cancelled-settlement-before-follow-up"); | ||
| 600 | const tempDir = yield* Effect.promise(() => | ||
| 601 | NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-acp-cancel-race-")), | ||
| 602 | ); | ||
| 603 | const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); | ||
| 604 | const wrapperPath = yield* Effect.promise(() => | ||
| 605 | makeMockGrokWrapper({ | ||
| 606 | T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", | ||
| 607 | T3_ACP_REQUEST_LOG_PATH: requestLogPath, | ||
| 608 | }), | ||
| 609 | ); | ||
| 610 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 611 | |||
| 612 | const runtimeEvents: ProviderRuntimeEvent[] = []; | ||
| 613 | const firstTurnStarted = yield* Deferred.make<TurnId>(); | ||
| 614 | const twoTurnsCompleted = yield* Deferred.make<void>(); | ||
| 615 | const completedCountRef = yield* Ref.make(0); | ||
| 616 | const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => | ||
| 617 | Effect.gen(function* () { | ||
| 618 | runtimeEvents.push(event); | ||
| 619 | if (String(event.threadId) !== String(threadId)) { | ||
| 620 | return; | ||
| 621 | } | ||
| 622 | if (event.type === "turn.started" && event.turnId !== undefined) { | ||
| 623 | yield* Deferred.succeed(firstTurnStarted, event.turnId).pipe(Effect.ignore); | ||
| 624 | return; | ||
| 625 | } | ||
| 626 | if (event.type !== "turn.completed") { | ||
| 627 | return; | ||
| 628 | } | ||
| 629 | const completedCount = yield* Ref.updateAndGet(completedCountRef, (count) => count + 1); | ||
| 630 | if (completedCount === 2) { | ||
| 631 | yield* Deferred.succeed(twoTurnsCompleted, undefined); | ||
| 632 | } | ||
| 633 | }), | ||
| 634 | ).pipe(Effect.forkChild); | ||
| 635 | |||
| 636 | yield* adapter.startSession({ | ||
| 637 | threadId, | ||
| 638 | provider: ProviderDriverKind.make("grok"), | ||
| 639 | cwd: process.cwd(), | ||
| 640 | runtimeMode: "full-access", | ||
| 641 | }); | ||
| 642 | |||
| 643 | const firstSendTurnFiber = yield* adapter | ||
| 644 | .sendTurn({ threadId, input: "cancel this prompt", attachments: [] }) | ||
| 645 | .pipe(Effect.forkChild); | ||
| 646 | const firstTurnId = yield* Deferred.await(firstTurnStarted).pipe(Effect.timeout("2 seconds")); | ||
| 647 | yield* waitForFileContent(requestLogPath, 80, '"method":"session/prompt"'); | ||
| 648 | |||
| 649 | yield* adapter.interruptTurn(threadId, firstTurnId).pipe(Effect.timeout("2 seconds")); | ||
| 650 | const followUp = yield* adapter | ||
| 651 | .sendTurn({ threadId, input: "complete the follow-up", attachments: [] }) | ||
| 652 | .pipe(Effect.timeout("2 seconds")); | ||
| 653 | yield* Fiber.join(firstSendTurnFiber).pipe(Effect.timeout("2 seconds")); | ||
| 654 | yield* Deferred.await(twoTurnsCompleted).pipe(Effect.timeout("2 seconds")); | ||
| 655 | |||
| 656 | const turnCompletedEvents = runtimeEvents.filter( | ||
| 657 | (event): event is Extract<ProviderRuntimeEvent, { type: "turn.completed" }> => | ||
| 658 | event.type === "turn.completed" && String(event.threadId) === String(threadId), | ||
| 659 | ); | ||
| 660 | const readySessions = yield* adapter.listSessions(); | ||
| 661 | const readySession = readySessions.find((session) => session.threadId === threadId); | ||
| 662 | |||
| 663 | assert.notEqual(String(followUp.turnId), String(firstTurnId)); | ||
| 664 | assert.deepEqual( | ||
| 665 | turnCompletedEvents.map((event) => [String(event.turnId), event.payload.state]), | ||
| 666 | [ | ||
| 667 | [String(firstTurnId), "cancelled"], | ||
| 668 | [String(followUp.turnId), "completed"], | ||
| 669 | ], | ||
| 670 | ); | ||
| 671 | assert.equal(readySession?.status, "ready"); | ||
| 672 | assert.isUndefined(readySession?.activeTurnId); | ||
| 673 | |||
| 674 | yield* Fiber.interrupt(runtimeEventsFiber); | ||
| 675 | yield* adapter.stopSession(threadId); | ||
| 676 | }).pipe(TestClock.withLive), | ||
| 677 | ); | ||
| 678 | |||
| 679 | it.effect("drops late ACP notifications after a turn is cancelled", () => | ||
| 680 | Effect.gen(function* () { | ||
| 681 | const threadId = ThreadId.make("grok-drop-late-cancelled-notifications"); | ||
| 682 | const wrapperPath = yield* Effect.promise(() => | ||
| 683 | makeMockGrokWrapper({ | ||
| 684 | T3_ACP_HANG_PROMPT_FOREVER: "1", | ||
| 685 | T3_ACP_EMIT_LATE_UPDATE_AFTER_CANCEL: "1", | ||
| 686 | }), | ||
| 687 | ); | ||
| 688 | const lateNativeUpdate = yield* Deferred.make<void>(); | ||
| 689 | const adapter = yield* makeTestAdapter(wrapperPath, { | ||
| 690 | nativeEventLogger: { | ||
| 691 | filePath: "memory://grok-cancelled-native-events", | ||
| 692 | write: (record: unknown) => | ||
| 693 | JSON.stringify(record).includes("late after cancel") | ||
| 694 | ? Deferred.succeed(lateNativeUpdate, undefined).pipe(Effect.asVoid) | ||
| 695 | : Effect.void, | ||
| 696 | close: () => Effect.void, | ||
| 697 | }, | ||
| 698 | }); | ||
| 699 | |||
| 700 | const runtimeEvents: ProviderRuntimeEvent[] = []; | ||
| 701 | const turnStarted = yield* Deferred.make<TurnId>(); | ||
| 702 | const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => | ||
| 703 | Effect.sync(() => { | ||
| 704 | runtimeEvents.push(event); | ||
| 705 | }).pipe( | ||
| 706 | Effect.andThen( | ||
| 707 | event.type === "turn.started" && | ||
| 708 | event.turnId !== undefined && | ||
| 709 | String(event.threadId) === String(threadId) | ||
| 710 | ? Deferred.succeed(turnStarted, event.turnId).pipe(Effect.asVoid) | ||
| 711 | : Effect.void, | ||
| 712 | ), | ||
| 713 | ), | ||
| 714 | ).pipe(Effect.forkChild); | ||
| 715 | |||
| 716 | yield* adapter.startSession({ | ||
| 717 | threadId, | ||
| 718 | provider: ProviderDriverKind.make("grok"), | ||
| 719 | cwd: process.cwd(), | ||
| 720 | runtimeMode: "full-access", | ||
| 721 | }); | ||
| 722 | |||
| 723 | const sendTurnFiber = yield* adapter | ||
| 724 | .sendTurn({ threadId, input: "cancel before the late update", attachments: [] }) | ||
| 725 | .pipe(Effect.forkChild); | ||
| 726 | const turnId = yield* Deferred.await(turnStarted).pipe(Effect.timeout("2 seconds")); | ||
| 727 | yield* adapter.interruptTurn(threadId, turnId).pipe(Effect.timeout("2 seconds")); | ||
| 728 | yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("2 seconds")); | ||
| 729 | yield* Deferred.await(lateNativeUpdate).pipe(Effect.timeout("2 seconds")); | ||
| 730 | for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { | ||
| 731 | yield* Effect.yieldNow; | ||
| 732 | } | ||
| 733 | |||
| 734 | const cancelledIndex = runtimeEvents.findIndex( | ||
| 735 | (event) => | ||
| 736 | event.type === "turn.completed" && | ||
| 737 | String(event.threadId) === String(threadId) && | ||
| 738 | String(event.turnId) === String(turnId) && | ||
| 739 | event.payload.state === "cancelled", | ||
| 740 | ); | ||
| 741 | const turnOutputTypes = new Set([ | ||
| 742 | "content.delta", | ||
| 743 | "item.started", | ||
| 744 | "item.updated", | ||
| 745 | "item.completed", | ||
| 746 | "turn.plan.updated", | ||
| 747 | ]); | ||
| 748 | const outputAfterCancellation = runtimeEvents | ||
| 749 | .slice(cancelledIndex + 1) | ||
| 750 | .filter( | ||
| 751 | (event) => String(event.threadId) === String(threadId) && turnOutputTypes.has(event.type), | ||
| 752 | ); | ||
| 753 | |||
| 754 | assert.isAtLeast(cancelledIndex, 0); | ||
| 755 | assert.deepEqual(outputAfterCancellation, []); | ||
| 756 | |||
| 757 | yield* Fiber.interrupt(runtimeEventsFiber); | ||
| 758 | yield* adapter.stopSession(threadId); | ||
| 759 | }).pipe(TestClock.withLive), | ||
| 760 | ); | ||
| 761 | |||
| 762 | it.effect("lets Stop cancel during the xAI completion drain window", () => | ||
| 763 | Effect.gen(function* () { | ||
| 764 | const threadId = ThreadId.make("grok-stop-during-completion-drain"); | ||
| 765 | const wrapperPath = yield* Effect.promise(() => | ||
| 766 | makeMockGrokWrapper({ | ||
| 767 | T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", | ||
| 768 | }), | ||
| 769 | ); | ||
| 770 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 771 | |||
| 772 | const runtimeEvents: ProviderRuntimeEvent[] = []; | ||
| 773 | const activeTurnIdRef = yield* Ref.make<TurnId | undefined>(undefined); | ||
| 774 | const trailingChunkTurnId = yield* Deferred.make<TurnId>(); | ||
| 775 | const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => | ||
| 776 | Effect.gen(function* () { | ||
| 777 | runtimeEvents.push(event); | ||
| 778 | if (String(event.threadId) !== String(threadId)) { | ||
| 779 | return; | ||
| 780 | } | ||
| 781 | if (event.type === "turn.started") { | ||
| 782 | yield* Ref.set(activeTurnIdRef, event.turnId); | ||
| 783 | } | ||
| 784 | if (event.type !== "content.delta" || event.payload.delta !== "mock") { | ||
| 785 | return; | ||
| 786 | } | ||
| 787 | const turnId = event.turnId ?? (yield* Ref.get(activeTurnIdRef)); | ||
| 788 | if (turnId === undefined) { | ||
| 789 | return; | ||
| 790 | } | ||
| 791 | yield* Deferred.succeed(trailingChunkTurnId, turnId).pipe(Effect.ignore); | ||
| 792 | }), | ||
| 793 | ).pipe(Effect.forkChild); | ||
| 794 | |||
| 795 | yield* adapter.startSession({ | ||
| 796 | threadId, | ||
| 797 | provider: ProviderDriverKind.make("grok"), | ||
| 798 | cwd: process.cwd(), | ||
| 799 | runtimeMode: "full-access", | ||
| 800 | modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, | ||
| 801 | }); | ||
| 802 | |||
| 803 | const sendTurnFiber = yield* adapter | ||
| 804 | .sendTurn({ | ||
| 805 | threadId, | ||
| 806 | input: "cancel during completion drain", | ||
| 807 | attachments: [], | ||
| 808 | }) | ||
| 809 | .pipe(Effect.forkChild); | ||
| 810 | |||
| 811 | const turnId = yield* Deferred.await(trailingChunkTurnId).pipe(Effect.timeout("2 seconds")); | ||
| 812 | yield* adapter.interruptTurn(threadId, turnId).pipe(Effect.timeout("2 seconds")); | ||
| 813 | yield* Fiber.join(sendTurnFiber).pipe(Effect.timeout("2 seconds")); | ||
| 814 | |||
| 815 | const turnCompletedEvents = runtimeEvents.filter( | ||
| 816 | (event): event is Extract<ProviderRuntimeEvent, { type: "turn.completed" }> => | ||
| 817 | event.type === "turn.completed" && String(event.threadId) === String(threadId), | ||
| 818 | ); | ||
| 819 | const readySessions = yield* adapter.listSessions(); | ||
| 820 | const readySession = readySessions.find((session) => session.threadId === threadId); | ||
| 821 | |||
| 822 | assert.lengthOf(turnCompletedEvents, 1); | ||
| 823 | assert.equal(turnCompletedEvents[0]?.payload.state, "cancelled"); | ||
| 824 | assert.equal(readySession?.status, "ready"); | ||
| 825 | assert.isUndefined(readySession?.activeTurnId); | ||
| 826 | |||
| 827 | yield* Fiber.interrupt(runtimeEventsFiber); | ||
| 828 | yield* adapter.stopSession(threadId); | ||
| 829 | }), | ||
| 830 | ); | ||
| 831 | |||
| 832 | it.effect("settles the in-flight prompt before emitting completion", () => | ||
| 833 | Effect.gen(function* () { | ||
| 834 | const threadId = ThreadId.make("grok-completion-before-next-turn"); | ||
| 835 | const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); | ||
| 836 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 837 | const completedCountRef = yield* Ref.make(0); | ||
| 838 | const secondTurnCompleted = yield* Deferred.make<void>(); | ||
| 839 | |||
| 840 | const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => { | ||
| 841 | if (event.type !== "turn.completed" || String(event.threadId) !== String(threadId)) { | ||
| 842 | return Effect.void; | ||
| 843 | } | ||
| 844 | |||
| 845 | return Ref.modify(completedCountRef, (count) => { | ||
| 846 | const nextCount = count + 1; | ||
| 847 | return [nextCount, nextCount] as const; | ||
| 848 | }).pipe( | ||
| 849 | Effect.flatMap((count) => { | ||
| 850 | if (count === 1) { | ||
| 851 | return adapter | ||
| 852 | .sendTurn({ | ||
| 853 | threadId, | ||
| 854 | input: "second turn after completion", | ||
| 855 | attachments: [], | ||
| 856 | }) | ||
| 857 | .pipe(Effect.forkChild, Effect.asVoid); | ||
| 858 | } | ||
| 859 | if (count === 2) { | ||
| 860 | return Deferred.succeed(secondTurnCompleted, undefined).pipe(Effect.asVoid); | ||
| 861 | } | ||
| 862 | return Effect.void; | ||
| 863 | }), | ||
| 864 | ); | ||
| 865 | }).pipe(Effect.forkChild); | ||
| 866 | |||
| 867 | yield* adapter.startSession({ | ||
| 868 | threadId, | ||
| 869 | provider: ProviderDriverKind.make("grok"), | ||
| 870 | cwd: process.cwd(), | ||
| 871 | runtimeMode: "full-access", | ||
| 872 | modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, | ||
| 873 | }); | ||
| 874 | |||
| 875 | yield* adapter.sendTurn({ | ||
| 876 | threadId, | ||
| 877 | input: "first turn", | ||
| 878 | attachments: [], | ||
| 879 | }); | ||
| 880 | yield* Deferred.await(secondTurnCompleted); | ||
| 881 | |||
| 882 | const completedCount = yield* Ref.get(completedCountRef); | ||
| 883 | const readySessions = yield* adapter.listSessions(); | ||
| 884 | const readySession = readySessions.find((session) => session.threadId === threadId); | ||
| 885 | |||
| 886 | assert.equal(completedCount, 2); | ||
| 887 | assert.equal(readySession?.status, "ready"); | ||
| 888 | assert.isUndefined(readySession?.activeTurnId); | ||
| 889 | |||
| 890 | yield* Fiber.interrupt(runtimeEventsFiber); | ||
| 891 | yield* adapter.stopSession(threadId); | ||
| 892 | }), | ||
| 893 | ); | ||
| 894 | |||
| 895 | it.effect("restores a Grok session to ready when the prompt RPC fails", () => | ||
| 896 | Effect.gen(function* () { | ||
| 897 | const threadId = ThreadId.make("grok-prompt-failure-ready"); | ||
| 898 | const wrapperPath = yield* Effect.promise(() => | ||
| 899 | makeMockGrokWrapper({ | ||
| 900 | T3_ACP_FAIL_PROMPT: "1", | ||
| 901 | }), | ||
| 902 | ); | ||
| 903 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 904 | const runtimeEvents: ProviderRuntimeEvent[] = []; | ||
| 905 | const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => | ||
| 906 | Effect.sync(() => { | ||
| 907 | runtimeEvents.push(event); | ||
| 908 | }), | ||
| 909 | ).pipe(Effect.forkChild); | ||
| 910 | |||
| 911 | yield* adapter.startSession({ | ||
| 912 | threadId, | ||
| 913 | provider: ProviderDriverKind.make("grok"), | ||
| 914 | cwd: process.cwd(), | ||
| 915 | runtimeMode: "full-access", | ||
| 916 | modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, | ||
| 917 | }); | ||
| 918 | |||
| 919 | const error = yield* Effect.flip( | ||
| 920 | adapter.sendTurn({ | ||
| 921 | threadId, | ||
| 922 | input: "fail prompt", | ||
| 923 | attachments: [], | ||
| 924 | }), | ||
| 925 | ); | ||
| 926 | const readySessions = yield* adapter.listSessions(); | ||
| 927 | const readySession = readySessions.find((session) => session.threadId === threadId); | ||
| 928 | const failedTurnCompleted = runtimeEvents.find( | ||
| 929 | (event) => event.type === "turn.completed" && event.threadId === threadId, | ||
| 930 | ); | ||
| 931 | |||
| 932 | assert.equal(error._tag, "ProviderAdapterRequestError"); | ||
| 933 | assert.equal(readySession?.status, "ready"); | ||
| 934 | assert.isUndefined(readySession?.activeTurnId); | ||
| 935 | assert.equal(failedTurnCompleted?.type, "turn.completed"); | ||
| 936 | if (failedTurnCompleted?.type === "turn.completed") { | ||
| 937 | assert.equal(failedTurnCompleted.payload.state, "failed"); | ||
| 938 | assert.isString(failedTurnCompleted.payload.errorMessage); | ||
| 939 | } | ||
| 940 | |||
| 941 | yield* Fiber.interrupt(runtimeEventsFiber); | ||
| 942 | yield* adapter.stopSession(threadId); | ||
| 943 | }), | ||
| 944 | ); | ||
| 945 | |||
| 946 | it.effect("ignores replayed session/load updates when resuming a Grok session", () => | ||
| 947 | Effect.gen(function* () { | ||
| 948 | const threadId = ThreadId.make("grok-load-replay-filter"); | ||
| 949 | const wrapperPath = yield* Effect.promise(() => | ||
| 950 | makeMockGrokWrapper({ | ||
| 951 | T3_ACP_EMIT_LOAD_REPLAY: "1", | ||
| 952 | }), | ||
| 953 | ); | ||
| 954 | const adapter = yield* makeTestAdapter(wrapperPath); | ||
| 955 | const runtimeEvents: ProviderRuntimeEvent[] = []; | ||
| 956 | const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => | ||
| 957 | Effect.sync(() => { | ||
| 958 | runtimeEvents.push(event); | ||
| 959 | }), | ||
| 960 | ).pipe(Effect.forkChild); | ||
| 961 | |||
| 962 | const session = yield* adapter.startSession({ | ||
| 963 | threadId, | ||
| 964 | provider: ProviderDriverKind.make("grok"), | ||
| 965 | cwd: process.cwd(), | ||
| 966 | runtimeMode: "full-access", | ||
| 967 | modelSelection: { instanceId: ProviderInstanceId.make("grok"), model: "grok-build" }, | ||
| 968 | resumeCursor: { schemaVersion: 1, sessionId: "mock-session-1" }, | ||
| 969 | }); | ||
| 970 | |||
| 971 | yield* adapter.sendTurn({ | ||
| 972 | threadId, | ||
| 973 | input: "after resume", | ||
| 974 | attachments: [], | ||
| 975 | }); | ||
| 976 | |||
| 977 | assert.deepStrictEqual(session.resumeCursor, { | ||
| 978 | schemaVersion: 1, | ||
| 979 | sessionId: "mock-session-1", | ||
| 980 | }); | ||
| 981 | assert.isFalse( | ||
| 982 | runtimeEvents.some( | ||
| 983 | (event) => event.type === "item.completed" && event.payload.title === "Replay tool", | ||
| 984 | ), | ||
| 985 | ); | ||
| 986 | assert.isFalse( | ||
| 987 | runtimeEvents.some( | ||
| 988 | (event) => | ||
| 989 | event.type === "content.delta" && event.payload.delta === "replayed assistant text", | ||
| 990 | ), | ||
| 991 | ); | ||
| 992 | |||
| 993 | yield* Fiber.interrupt(runtimeEventsFiber); | ||
| 994 | yield* adapter.stopSession(threadId); | ||
| 995 | }), | ||
| 996 | ); | ||
| 997 | |||
| 178 | it.effect("rejects startSession when provider mismatches", () => | 998 | it.effect("rejects startSession when provider mismatches", () => |
| 179 | Effect.gen(function* () { | 999 | Effect.gen(function* () { |
| 180 | const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); | 1000 | const wrapperPath = yield* Effect.promise(() => makeMockGrokWrapper()); |
| ... | @@ -331,6 +1151,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { | ... | @@ -331,6 +1151,7 @@ it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { |
| 331 | assert.deepEqual(resolvedEvent.payload.answers, { | 1151 | assert.deepEqual(resolvedEvent.payload.answers, { |
| 332 | "Which scope should Grok use?": "Workspace", | 1152 | "Which scope should Grok use?": "Workspace", |
| 333 | }); | 1153 | }); |
| 1154 | assert.equal(String(resolvedEvent.turnId), String(requestedEvent.turnId)); | ||
| 334 | yield* Fiber.join(sendTurnFiber); | 1155 | yield* Fiber.join(sendTurnFiber); |
| 335 | 1156 | ||
| 336 | yield* Fiber.interrupt(eventsFiber); | 1157 | yield* Fiber.interrupt(eventsFiber); |
apps/server/src/provider/Layers/GrokAdapter.ts+512-56| ... | @@ -22,6 +22,7 @@ import * as FileSystem from "effect/FileSystem"; | ... | @@ -22,6 +22,7 @@ import * as FileSystem from "effect/FileSystem"; |
| 22 | import * as Option from "effect/Option"; | 22 | import * as Option from "effect/Option"; |
| 23 | import * as Path from "effect/Path"; | 23 | import * as Path from "effect/Path"; |
| 24 | import * as PubSub from "effect/PubSub"; | 24 | import * as PubSub from "effect/PubSub"; |
| 25 | import * as Ref from "effect/Ref"; | ||
| 25 | import * as Schema from "effect/Schema"; | 26 | import * as Schema from "effect/Schema"; |
| 26 | import * as Scope from "effect/Scope"; | 27 | import * as Scope from "effect/Scope"; |
| 27 | import * as Semaphore from "effect/Semaphore"; | 28 | import * as Semaphore from "effect/Semaphore"; |
| ... | @@ -62,6 +63,7 @@ import { | ... | @@ -62,6 +63,7 @@ import { |
| 62 | extractXAiAskUserQuestions, | 63 | extractXAiAskUserQuestions, |
| 63 | makeXAiAskUserQuestionCancelledResponse, | 64 | makeXAiAskUserQuestionCancelledResponse, |
| 64 | makeXAiAskUserQuestionResponse, | 65 | makeXAiAskUserQuestionResponse, |
| 66 | promptResponseHasMissingXAiStopReason, | ||
| 65 | XAiAskUserQuestionRequest, | 67 | XAiAskUserQuestionRequest, |
| 66 | } from "../acp/XAiAcpExtension.ts"; | 68 | } from "../acp/XAiAcpExtension.ts"; |
| 67 | import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; | 69 | import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; |
| ... | @@ -108,6 +110,8 @@ interface GrokSessionContext { | ... | @@ -108,6 +110,8 @@ interface GrokSessionContext { |
| 108 | turns: Array<{ id: TurnId; items: Array<unknown> }>; | 110 | turns: Array<{ id: TurnId; items: Array<unknown> }>; |
| 109 | lastPlanFingerprint: string | undefined; | 111 | lastPlanFingerprint: string | undefined; |
| 110 | activeTurnId: TurnId | undefined; | 112 | activeTurnId: TurnId | undefined; |
| 113 | /** Turns already interrupted; late prompt RPCs must not resurrect them. */ | ||
| 114 | interruptedTurnIds: Set<TurnId>; | ||
| 111 | /** Number of sendTurn prompts currently in flight or being prepared. | 115 | /** Number of sendTurn prompts currently in flight or being prepared. |
| 112 | * >0 means a turn is actively running, so a new sendTurn is a steer that | 116 | * >0 means a turn is actively running, so a new sendTurn is a steer that |
| 113 | * continues it, and only the last remaining prompt settles the turn. */ | 117 | * continues it, and only the last remaining prompt settles the turn. */ |
| ... | @@ -136,10 +140,38 @@ function settlePendingUserInputsAsCancelled( | ... | @@ -136,10 +140,38 @@ function settlePendingUserInputsAsCancelled( |
| 136 | ); | 140 | ); |
| 137 | } | 141 | } |
| 138 | 142 | ||
| 143 | function appendPromptResultToTurn( | ||
| 144 | ctx: GrokSessionContext, | ||
| 145 | turnId: TurnId, | ||
| 146 | promptParts: ReadonlyArray<EffectAcpSchema.ContentBlock>, | ||
| 147 | result: EffectAcpSchema.PromptResponse, | ||
| 148 | ): void { | ||
| 149 | const existingTurnRecord = ctx.turns.find((turn) => turn.id === turnId); | ||
| 150 | ctx.turns = existingTurnRecord | ||
| 151 | ? ctx.turns.map((turn) => | ||
| 152 | turn.id === turnId | ||
| 153 | ? { ...turn, items: [...turn.items, { prompt: promptParts, result }] } | ||
| 154 | : turn, | ||
| 155 | ) | ||
| 156 | : [...ctx.turns, { id: turnId, items: [{ prompt: promptParts, result }] }]; | ||
| 157 | } | ||
| 158 | |||
| 139 | function isRecord(value: unknown): value is Record<string, unknown> { | 159 | function isRecord(value: unknown): value is Record<string, unknown> { |
| 140 | return typeof value === "object" && value !== null && !Array.isArray(value); | 160 | return typeof value === "object" && value !== null && !Array.isArray(value); |
| 141 | } | 161 | } |
| 142 | 162 | ||
| 163 | const resolveNotificationTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId; | ||
| 164 | |||
| 165 | const resolveCallbackTurnId = (ctx: GrokSessionContext): TurnId | undefined => ctx.activeTurnId; | ||
| 166 | |||
| 167 | const resolveSessionCallbackTurnId = ( | ||
| 168 | sessions: ReadonlyMap<ThreadId, GrokSessionContext>, | ||
| 169 | threadId: ThreadId, | ||
| 170 | ): TurnId | undefined => { | ||
| 171 | const ctx = sessions.get(threadId); | ||
| 172 | return ctx ? resolveCallbackTurnId(ctx) : undefined; | ||
| 173 | }; | ||
| 174 | |||
| 143 | function parseGrokResume(raw: unknown): { sessionId: string } | undefined { | 175 | function parseGrokResume(raw: unknown): { sessionId: string } | undefined { |
| 144 | if (!isRecord(raw)) return undefined; | 176 | if (!isRecord(raw)) return undefined; |
| 145 | if (raw.schemaVersion !== GROK_RESUME_VERSION) return undefined; | 177 | if (raw.schemaVersion !== GROK_RESUME_VERSION) return undefined; |
| ... | @@ -170,6 +202,28 @@ function selectAutoApprovedPermissionOption( | ... | @@ -170,6 +202,28 @@ function selectAutoApprovedPermissionOption( |
| 170 | ); | 202 | ); |
| 171 | } | 203 | } |
| 172 | 204 | ||
| 205 | function completedStopReasonFromPromptResponse( | ||
| 206 | response: EffectAcpSchema.PromptResponse | undefined, | ||
| 207 | ): EffectAcpSchema.StopReason | null { | ||
| 208 | if (response === undefined || promptResponseHasMissingXAiStopReason(response)) { | ||
| 209 | return null; | ||
| 210 | } | ||
| 211 | return response.stopReason; | ||
| 212 | } | ||
| 213 | |||
| 214 | export function grokPromptSettlementBelongsToContext(input: { | ||
| 215 | readonly liveAcpSessionId: string; | ||
| 216 | readonly expectedAcpSessionId: string; | ||
| 217 | readonly liveActiveTurnId: TurnId | undefined; | ||
| 218 | readonly liveSessionActiveTurnId: TurnId | undefined; | ||
| 219 | readonly turnId: TurnId; | ||
| 220 | }): boolean { | ||
| 221 | return ( | ||
| 222 | input.liveAcpSessionId === input.expectedAcpSessionId && | ||
| 223 | (input.liveActiveTurnId === input.turnId || input.liveSessionActiveTurnId === input.turnId) | ||
| 224 | ); | ||
| 225 | } | ||
| 226 | |||
| 173 | export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapterLiveOptions) { | 227 | export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapterLiveOptions) { |
| 174 | return Effect.gen(function* () { | 228 | return Effect.gen(function* () { |
| 175 | const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("grok"); | 229 | const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("grok"); |
| ... | @@ -240,6 +294,144 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -240,6 +294,144 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 240 | const withThreadLock = <A, E, R>(threadId: string, effect: Effect.Effect<A, E, R>) => | 294 | const withThreadLock = <A, E, R>(threadId: string, effect: Effect.Effect<A, E, R>) => |
| 241 | Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); | 295 | Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); |
| 242 | 296 | ||
| 297 | const settlePromptInFlight = ( | ||
| 298 | threadId: ThreadId, | ||
| 299 | turnId: TurnId, | ||
| 300 | expectedAcpSessionId: string, | ||
| 301 | options?: { | ||
| 302 | readonly errorMessage?: string; | ||
| 303 | readonly completedStopReason?: EffectAcpSchema.StopReason | null; | ||
| 304 | readonly emitTurnCompletion?: boolean; | ||
| 305 | /** Interrupt/cancel: drop every outstanding prompt slot and settle once. */ | ||
| 306 | readonly settleAllPrompts?: boolean; | ||
| 307 | }, | ||
| 308 | ) => | ||
| 309 | Effect.gen(function* () { | ||
| 310 | const liveCtx = sessions.get(threadId); | ||
| 311 | if (!liveCtx) { | ||
| 312 | return; | ||
| 313 | } | ||
| 314 | const settlementBelongsToLiveContext = grokPromptSettlementBelongsToContext({ | ||
| 315 | liveAcpSessionId: liveCtx.acpSessionId, | ||
| 316 | expectedAcpSessionId, | ||
| 317 | liveActiveTurnId: liveCtx.activeTurnId, | ||
| 318 | liveSessionActiveTurnId: liveCtx.session.activeTurnId, | ||
| 319 | turnId, | ||
| 320 | }); | ||
| 321 | if (!settlementBelongsToLiveContext) { | ||
| 322 | // interruptTurn already consumed every prompt slot for this turn. A | ||
| 323 | // late prompt result must neither emit a second terminal event nor | ||
| 324 | // consume a slot belonging to a newer turn on the same ACP session. | ||
| 325 | if ( | ||
| 326 | liveCtx.acpSessionId !== expectedAcpSessionId || | ||
| 327 | liveCtx.interruptedTurnIds.has(turnId) | ||
| 328 | ) { | ||
| 329 | return; | ||
| 330 | } | ||
| 331 | if (options?.emitTurnCompletion !== false) { | ||
| 332 | if (options?.errorMessage !== undefined) { | ||
| 333 | yield* offerRuntimeEvent({ | ||
| 334 | type: "turn.completed", | ||
| 335 | ...(yield* makeEventStamp()), | ||
| 336 | provider: PROVIDER, | ||
| 337 | threadId, | ||
| 338 | turnId, | ||
| 339 | payload: { | ||
| 340 | state: "failed", | ||
| 341 | errorMessage: options.errorMessage, | ||
| 342 | }, | ||
| 343 | }); | ||
| 344 | } else if (options?.completedStopReason !== undefined) { | ||
| 345 | yield* offerRuntimeEvent({ | ||
| 346 | type: "turn.completed", | ||
| 347 | ...(yield* makeEventStamp()), | ||
| 348 | provider: PROVIDER, | ||
| 349 | threadId, | ||
| 350 | turnId, | ||
| 351 | payload: { | ||
| 352 | state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", | ||
| 353 | stopReason: options.completedStopReason ?? null, | ||
| 354 | }, | ||
| 355 | }); | ||
| 356 | } | ||
| 357 | } | ||
| 358 | return; | ||
| 359 | } | ||
| 360 | let settleTurnId = turnId; | ||
| 361 | if (options?.settleAllPrompts) { | ||
| 362 | liveCtx.promptsInFlight = 0; | ||
| 363 | if (liveCtx.activeTurnId !== turnId && liveCtx.session.activeTurnId !== turnId) { | ||
| 364 | const fallbackTurnId = liveCtx.activeTurnId ?? liveCtx.session.activeTurnId; | ||
| 365 | if (!fallbackTurnId) { | ||
| 366 | if (liveCtx.session.status === "running" || liveCtx.session.status === "connecting") { | ||
| 367 | const updatedAt = yield* nowIso; | ||
| 368 | const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; | ||
| 369 | liveCtx.activeTurnId = undefined; | ||
| 370 | liveCtx.session = { | ||
| 371 | ...readySession, | ||
| 372 | status: "ready", | ||
| 373 | updatedAt, | ||
| 374 | }; | ||
| 375 | } | ||
| 376 | return; | ||
| 377 | } | ||
| 378 | settleTurnId = fallbackTurnId; | ||
| 379 | } | ||
| 380 | } else { | ||
| 381 | const remainingPrompts = Math.max(0, liveCtx.promptsInFlight - 1); | ||
| 382 | if ( | ||
| 383 | remainingPrompts > 0 || | ||
| 384 | liveCtx.activeTurnId !== settleTurnId || | ||
| 385 | liveCtx.session.activeTurnId !== settleTurnId | ||
| 386 | ) { | ||
| 387 | liveCtx.promptsInFlight = remainingPrompts; | ||
| 388 | return; | ||
| 389 | } | ||
| 390 | liveCtx.promptsInFlight = remainingPrompts; | ||
| 391 | } | ||
| 392 | const updatedAt = yield* nowIso; | ||
| 393 | const canEmitTurnCompletion = | ||
| 394 | liveCtx.session.status === "running" || liveCtx.session.status === "connecting"; | ||
| 395 | const shouldEmitFailedTurn = options?.errorMessage !== undefined && canEmitTurnCompletion; | ||
| 396 | const shouldEmitCompletedTurn = | ||
| 397 | options?.completedStopReason !== undefined && canEmitTurnCompletion; | ||
| 398 | const { activeTurnId: _activeTurnId, ...readySession } = liveCtx.session; | ||
| 399 | liveCtx.activeTurnId = undefined; | ||
| 400 | liveCtx.session = { | ||
| 401 | ...readySession, | ||
| 402 | status: "ready", | ||
| 403 | updatedAt, | ||
| 404 | }; | ||
| 405 | if (options?.emitTurnCompletion === false) { | ||
| 406 | return; | ||
| 407 | } | ||
| 408 | if (shouldEmitFailedTurn) { | ||
| 409 | yield* offerRuntimeEvent({ | ||
| 410 | type: "turn.completed", | ||
| 411 | ...(yield* makeEventStamp()), | ||
| 412 | provider: PROVIDER, | ||
| 413 | threadId, | ||
| 414 | turnId: settleTurnId, | ||
| 415 | payload: { | ||
| 416 | state: "failed", | ||
| 417 | errorMessage: options.errorMessage, | ||
| 418 | }, | ||
| 419 | }); | ||
| 420 | } else if (shouldEmitCompletedTurn) { | ||
| 421 | yield* offerRuntimeEvent({ | ||
| 422 | type: "turn.completed", | ||
| 423 | ...(yield* makeEventStamp()), | ||
| 424 | provider: PROVIDER, | ||
| 425 | threadId, | ||
| 426 | turnId: settleTurnId, | ||
| 427 | payload: { | ||
| 428 | state: options.completedStopReason === "cancelled" ? "cancelled" : "completed", | ||
| 429 | stopReason: options.completedStopReason ?? null, | ||
| 430 | }, | ||
| 431 | }); | ||
| 432 | } | ||
| 433 | }); | ||
| 434 | |||
| 243 | const logNative = (threadId: ThreadId, method: string, payload: unknown) => | 435 | const logNative = (threadId: ThreadId, method: string, payload: unknown) => |
| 244 | Effect.gen(function* () { | 436 | Effect.gen(function* () { |
| 245 | if (!nativeEventLogger) return; | 437 | if (!nativeEventLogger) return; |
| ... | @@ -271,6 +463,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -271,6 +463,8 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 271 | 463 | ||
| 272 | const emitPlanUpdate = ( | 464 | const emitPlanUpdate = ( |
| 273 | ctx: GrokSessionContext, | 465 | ctx: GrokSessionContext, |
| 466 | turnId: TurnId | undefined, | ||
| 467 | stamp: { readonly eventId: EventId; readonly createdAt: string }, | ||
| 274 | payload: { | 468 | payload: { |
| 275 | readonly explanation?: string | null; | 469 | readonly explanation?: string | null; |
| 276 | readonly plan: ReadonlyArray<{ | 470 | readonly plan: ReadonlyArray<{ |
| ... | @@ -282,17 +476,17 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -282,17 +476,17 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 282 | method: string, | 476 | method: string, |
| 283 | ) => | 477 | ) => |
| 284 | Effect.gen(function* () { | 478 | Effect.gen(function* () { |
| 285 | const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; | 479 | const fingerprint = `${turnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; |
| 286 | if (ctx.lastPlanFingerprint === fingerprint) { | 480 | if (ctx.lastPlanFingerprint === fingerprint) { |
| 287 | return; | 481 | return; |
| 288 | } | 482 | } |
| 289 | ctx.lastPlanFingerprint = fingerprint; | 483 | ctx.lastPlanFingerprint = fingerprint; |
| 290 | yield* offerRuntimeEvent( | 484 | yield* offerRuntimeEvent( |
| 291 | makeAcpPlanUpdatedEvent({ | 485 | makeAcpPlanUpdatedEvent({ |
| 292 | stamp: yield* makeEventStamp(), | 486 | stamp, |
| 293 | provider: PROVIDER, | 487 | provider: PROVIDER, |
| 294 | threadId: ctx.threadId, | 488 | threadId: ctx.threadId, |
| 295 | turnId: ctx.activeTurnId, | 489 | turnId, |
| 296 | payload, | 490 | payload, |
| 297 | source: "acp.jsonrpc", | 491 | source: "acp.jsonrpc", |
| 298 | method, | 492 | method, |
| ... | @@ -424,13 +618,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -424,13 +618,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 424 | const requestId = ApprovalRequestId.make(yield* randomUUIDv4); | 618 | const requestId = ApprovalRequestId.make(yield* randomUUIDv4); |
| 425 | const runtimeRequestId = RuntimeRequestId.make(requestId); | 619 | const runtimeRequestId = RuntimeRequestId.make(requestId); |
| 426 | const resolution = yield* Deferred.make<PendingUserInputResolution>(); | 620 | const resolution = yield* Deferred.make<PendingUserInputResolution>(); |
| 621 | const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); | ||
| 427 | pendingUserInputs.set(requestId, { resolution }); | 622 | pendingUserInputs.set(requestId, { resolution }); |
| 428 | yield* offerRuntimeEvent({ | 623 | yield* offerRuntimeEvent({ |
| 429 | type: "user-input.requested", | 624 | type: "user-input.requested", |
| 430 | ...(yield* makeEventStamp()), | 625 | ...(yield* makeEventStamp()), |
| 431 | provider: PROVIDER, | 626 | provider: PROVIDER, |
| 432 | threadId: input.threadId, | 627 | threadId: input.threadId, |
| 433 | turnId: sessions.get(input.threadId)?.activeTurnId, | 628 | turnId, |
| 434 | requestId: runtimeRequestId, | 629 | requestId: runtimeRequestId, |
| 435 | payload: { questions: extractXAiAskUserQuestions(params) }, | 630 | payload: { questions: extractXAiAskUserQuestions(params) }, |
| 436 | raw: { | 631 | raw: { |
| ... | @@ -447,7 +642,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -447,7 +642,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 447 | ...(yield* makeEventStamp()), | 642 | ...(yield* makeEventStamp()), |
| 448 | provider: PROVIDER, | 643 | provider: PROVIDER, |
| 449 | threadId: input.threadId, | 644 | threadId: input.threadId, |
| 450 | turnId: sessions.get(input.threadId)?.activeTurnId, | 645 | turnId, |
| 451 | requestId: runtimeRequestId, | 646 | requestId: runtimeRequestId, |
| 452 | payload: { answers: resolvedAnswers }, | 647 | payload: { answers: resolvedAnswers }, |
| 453 | raw: { | 648 | raw: { |
| ... | @@ -486,13 +681,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -486,13 +681,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 486 | const requestId = ApprovalRequestId.make(yield* randomUUIDv4); | 681 | const requestId = ApprovalRequestId.make(yield* randomUUIDv4); |
| 487 | const runtimeRequestId = RuntimeRequestId.make(requestId); | 682 | const runtimeRequestId = RuntimeRequestId.make(requestId); |
| 488 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); | 683 | const decision = yield* Deferred.make<ProviderApprovalDecision>(); |
| 684 | const turnId = resolveSessionCallbackTurnId(sessions, input.threadId); | ||
| 489 | pendingApprovals.set(requestId, { decision }); | 685 | pendingApprovals.set(requestId, { decision }); |
| 490 | yield* offerRuntimeEvent( | 686 | yield* offerRuntimeEvent( |
| 491 | makeAcpRequestOpenedEvent({ | 687 | makeAcpRequestOpenedEvent({ |
| 492 | stamp: yield* makeEventStamp(), | 688 | stamp: yield* makeEventStamp(), |
| 493 | provider: PROVIDER, | 689 | provider: PROVIDER, |
| 494 | threadId: input.threadId, | 690 | threadId: input.threadId, |
| 495 | turnId: sessions.get(input.threadId)?.activeTurnId, | 691 | turnId, |
| 496 | requestId: runtimeRequestId, | 692 | requestId: runtimeRequestId, |
| 497 | permissionRequest, | 693 | permissionRequest, |
| 498 | detail: | 694 | detail: |
| ... | @@ -512,7 +708,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -512,7 +708,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 512 | stamp: yield* makeEventStamp(), | 708 | stamp: yield* makeEventStamp(), |
| 513 | provider: PROVIDER, | 709 | provider: PROVIDER, |
| 514 | threadId: input.threadId, | 710 | threadId: input.threadId, |
| 515 | turnId: sessions.get(input.threadId)?.activeTurnId, | 711 | turnId, |
| 516 | requestId: runtimeRequestId, | 712 | requestId: runtimeRequestId, |
| 517 | permissionRequest, | 713 | permissionRequest, |
| 518 | decision: resolved, | 714 | decision: resolved, |
| ... | @@ -578,6 +774,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -578,6 +774,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 578 | turns: [], | 774 | turns: [], |
| 579 | lastPlanFingerprint: undefined, | 775 | lastPlanFingerprint: undefined, |
| 580 | activeTurnId: undefined, | 776 | activeTurnId: undefined, |
| 777 | interruptedTurnIds: new Set(), | ||
| 581 | promptsInFlight: 0, | 778 | promptsInFlight: 0, |
| 582 | currentModelId: boundModelId, | 779 | currentModelId: boundModelId, |
| 583 | stopped: false, | 780 | stopped: false, |
| ... | @@ -586,14 +783,39 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -586,14 +783,39 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 586 | const nf = yield* Stream.runDrain( | 783 | const nf = yield* Stream.runDrain( |
| 587 | Stream.mapEffect(acp.getEvents(), (event) => | 784 | Stream.mapEffect(acp.getEvents(), (event) => |
| 588 | Effect.gen(function* () { | 785 | Effect.gen(function* () { |
| 786 | if (event._tag === "EventStreamBarrier") { | ||
| 787 | yield* Deferred.succeed(event.acknowledge, undefined); | ||
| 788 | return; | ||
| 789 | } | ||
| 790 | if ( | ||
| 791 | event._tag === "PlanUpdated" || | ||
| 792 | event._tag === "ToolCallUpdated" || | ||
| 793 | event._tag === "ContentDelta" | ||
| 794 | ) { | ||
| 795 | yield* logNative(ctx.threadId, "session/update", event.rawPayload); | ||
| 796 | } | ||
| 797 | |||
| 798 | if (event._tag === "ModeChanged") { | ||
| 799 | return; | ||
| 800 | } | ||
| 801 | |||
| 802 | const notificationTurnId = resolveNotificationTurnId(ctx); | ||
| 803 | if ( | ||
| 804 | notificationTurnId === undefined || | ||
| 805 | ctx.interruptedTurnIds.has(notificationTurnId) | ||
| 806 | ) { | ||
| 807 | return; | ||
| 808 | } | ||
| 809 | const stamp = yield* makeEventStamp(); | ||
| 810 | |||
| 589 | switch (event._tag) { | 811 | switch (event._tag) { |
| 590 | case "AssistantItemStarted": | 812 | case "AssistantItemStarted": |
| 591 | yield* offerRuntimeEvent( | 813 | yield* offerRuntimeEvent( |
| 592 | makeAcpAssistantItemEvent({ | 814 | makeAcpAssistantItemEvent({ |
| 593 | stamp: yield* makeEventStamp(), | 815 | stamp, |
| 594 | provider: PROVIDER, | 816 | provider: PROVIDER, |
| 595 | threadId: ctx.threadId, | 817 | threadId: ctx.threadId, |
| 596 | turnId: ctx.activeTurnId, | 818 | turnId: notificationTurnId, |
| 597 | itemId: event.itemId, | 819 | itemId: event.itemId, |
| 598 | lifecycle: "item.started", | 820 | lifecycle: "item.started", |
| 599 | }), | 821 | }), |
| ... | @@ -602,40 +824,44 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -602,40 +824,44 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 602 | case "AssistantItemCompleted": | 824 | case "AssistantItemCompleted": |
| 603 | yield* offerRuntimeEvent( | 825 | yield* offerRuntimeEvent( |
| 604 | makeAcpAssistantItemEvent({ | 826 | makeAcpAssistantItemEvent({ |
| 605 | stamp: yield* makeEventStamp(), | 827 | stamp, |
| 606 | provider: PROVIDER, | 828 | provider: PROVIDER, |
| 607 | threadId: ctx.threadId, | 829 | threadId: ctx.threadId, |
| 608 | turnId: ctx.activeTurnId, | 830 | turnId: notificationTurnId, |
| 609 | itemId: event.itemId, | 831 | itemId: event.itemId, |
| 610 | lifecycle: "item.completed", | 832 | lifecycle: "item.completed", |
| 611 | }), | 833 | }), |
| 612 | ); | 834 | ); |
| 613 | return; | 835 | return; |
| 614 | case "PlanUpdated": | 836 | case "PlanUpdated": |
| 615 | yield* logNative(ctx.threadId, "session/update", event.rawPayload); | 837 | yield* emitPlanUpdate( |
| 616 | yield* emitPlanUpdate(ctx, event.payload, event.rawPayload, "session/update"); | 838 | ctx, |
| 839 | notificationTurnId, | ||
| 840 | stamp, | ||
| 841 | event.payload, | ||
| 842 | event.rawPayload, | ||
| 843 | "session/update", | ||
| 844 | ); | ||
| 617 | return; | 845 | return; |
| 618 | case "ToolCallUpdated": | 846 | case "ToolCallUpdated": |
| 619 | yield* logNative(ctx.threadId, "session/update", event.rawPayload); | ||
| 620 | yield* offerRuntimeEvent( | 847 | yield* offerRuntimeEvent( |
| 621 | makeAcpToolCallEvent({ | 848 | makeAcpToolCallEvent({ |
| 622 | stamp: yield* makeEventStamp(), | 849 | stamp, |
| 623 | provider: PROVIDER, | 850 | provider: PROVIDER, |
| 624 | threadId: ctx.threadId, | 851 | threadId: ctx.threadId, |
| 625 | turnId: ctx.activeTurnId, | 852 | turnId: notificationTurnId, |
| 626 | toolCall: event.toolCall, | 853 | toolCall: event.toolCall, |
| 627 | rawPayload: event.rawPayload, | 854 | rawPayload: event.rawPayload, |
| 628 | }), | 855 | }), |
| 629 | ); | 856 | ); |
| 630 | return; | 857 | return; |
| 631 | case "ContentDelta": | 858 | case "ContentDelta": |
| 632 | yield* logNative(ctx.threadId, "session/update", event.rawPayload); | ||
| 633 | yield* offerRuntimeEvent( | 859 | yield* offerRuntimeEvent( |
| 634 | makeAcpContentDeltaEvent({ | 860 | makeAcpContentDeltaEvent({ |
| 635 | stamp: yield* makeEventStamp(), | 861 | stamp, |
| 636 | provider: PROVIDER, | 862 | provider: PROVIDER, |
| 637 | threadId: ctx.threadId, | 863 | threadId: ctx.threadId, |
| 638 | turnId: ctx.activeTurnId, | 864 | turnId: notificationTurnId, |
| 639 | ...(event.itemId ? { itemId: event.itemId } : {}), | 865 | ...(event.itemId ? { itemId: event.itemId } : {}), |
| 640 | text: event.text, | 866 | text: event.text, |
| 641 | rawPayload: event.rawPayload, | 867 | rawPayload: event.rawPayload, |
| ... | @@ -697,6 +923,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -697,6 +923,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 697 | // resolving from here on does not settle the turn; decremented on | 923 | // resolving from here on does not settle the turn; decremented on |
| 698 | // preparation failure here, and after the prompt below otherwise. | 924 | // preparation failure here, and after the prompt below otherwise. |
| 699 | ctx.promptsInFlight += 1; | 925 | ctx.promptsInFlight += 1; |
| 926 | // Bind the turn id before cooperative yields so interruptTurn can | ||
| 927 | // settle this prompt even if stop arrives during preparation. | ||
| 928 | ctx.activeTurnId = turnId; | ||
| 929 | ctx.session = { | ||
| 930 | ...ctx.session, | ||
| 931 | status: steeringTurnId === undefined ? "connecting" : "running", | ||
| 932 | activeTurnId: turnId, | ||
| 933 | updatedAt: yield* nowIso, | ||
| 934 | }; | ||
| 700 | 935 | ||
| 701 | return yield* Effect.gen(function* () { | 936 | return yield* Effect.gen(function* () { |
| 702 | const turnModelSelection = | 937 | const turnModelSelection = |
| ... | @@ -765,12 +1000,27 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -765,12 +1000,27 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 765 | const displayModel = currentModelId | 1000 | const displayModel = currentModelId |
| 766 | ? resolveGrokAcpBaseModelId(currentModelId) | 1001 | ? resolveGrokAcpBaseModelId(currentModelId) |
| 767 | : undefined; | 1002 | : undefined; |
| 768 | ctx.activeTurnId = turnId; | 1003 | for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { |
| 1004 | yield* Effect.yieldNow; | ||
| 1005 | } | ||
| 1006 | if (ctx.interruptedTurnIds.has(turnId)) { | ||
| 1007 | yield* settlePromptInFlight(input.threadId, turnId, ctx.acpSessionId, { | ||
| 1008 | completedStopReason: "cancelled", | ||
| 1009 | emitTurnCompletion: false, | ||
| 1010 | settleAllPrompts: true, | ||
| 1011 | }); | ||
| 1012 | return yield* new ProviderAdapterRequestError({ | ||
| 1013 | provider: PROVIDER, | ||
| 1014 | method: "session/prompt", | ||
| 1015 | detail: "Grok prompt was interrupted during preparation.", | ||
| 1016 | }); | ||
| 1017 | } | ||
| 769 | if (steeringTurnId === undefined) { | 1018 | if (steeringTurnId === undefined) { |
| 770 | ctx.lastPlanFingerprint = undefined; | 1019 | ctx.lastPlanFingerprint = undefined; |
| 771 | } | 1020 | } |
| 772 | ctx.session = { | 1021 | ctx.session = { |
| 773 | ...ctx.session, | 1022 | ...ctx.session, |
| 1023 | status: "running", | ||
| 774 | activeTurnId: turnId, | 1024 | activeTurnId: turnId, |
| 775 | updatedAt: yield* nowIso, | 1025 | updatedAt: yield* nowIso, |
| 776 | ...(displayModel ? { model: displayModel } : {}), | 1026 | ...(displayModel ? { model: displayModel } : {}), |
| ... | @@ -796,13 +1046,27 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -796,13 +1046,27 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 796 | }; | 1046 | }; |
| 797 | }).pipe( | 1047 | }).pipe( |
| 798 | Effect.tapCause(() => | 1048 | Effect.tapCause(() => |
| 799 | Effect.sync(() => { | 1049 | Effect.gen(function* () { |
| 800 | ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); | 1050 | const liveCtx = sessions.get(input.threadId); |
| 1051 | if (!liveCtx) { | ||
| 1052 | return; | ||
| 1053 | } | ||
| 1054 | yield* settlePromptInFlight(input.threadId, turnId, liveCtx.acpSessionId, { | ||
| 1055 | errorMessage: "Grok prompt preparation failed.", | ||
| 1056 | emitTurnCompletion: false, | ||
| 1057 | }); | ||
| 801 | }), | 1058 | }), |
| 802 | ), | 1059 | ), |
| 803 | ); | 1060 | ); |
| 804 | }), | 1061 | }), |
| 805 | ); | 1062 | ); |
| 1063 | const promptSettled = yield* Ref.make(false); | ||
| 1064 | const promptRpcSucceeded = yield* Ref.make(false); | ||
| 1065 | const promptResultRef = yield* Ref.make<EffectAcpSchema.PromptResponse | undefined>( | ||
| 1066 | undefined, | ||
| 1067 | ); | ||
| 1068 | |||
| 1069 | const promptFailureMessageRef = yield* Ref.make<string | undefined>(undefined); | ||
| 806 | 1070 | ||
| 807 | return yield* Effect.gen(function* () { | 1071 | return yield* Effect.gen(function* () { |
| 808 | const result = yield* prepared.acp | 1072 | const result = yield* prepared.acp |
| ... | @@ -810,6 +1074,18 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -810,6 +1074,18 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 810 | prompt: prepared.promptParts, | 1074 | prompt: prepared.promptParts, |
| 811 | }) | 1075 | }) |
| 812 | .pipe( | 1076 | .pipe( |
| 1077 | Effect.tap((promptResult) => | ||
| 1078 | Effect.all([ | ||
| 1079 | Ref.set(promptRpcSucceeded, true), | ||
| 1080 | Ref.set(promptResultRef, promptResult), | ||
| 1081 | ]), | ||
| 1082 | ), | ||
| 1083 | Effect.tapError((error) => | ||
| 1084 | Ref.set( | ||
| 1085 | promptFailureMessageRef, | ||
| 1086 | mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error).message, | ||
| 1087 | ).pipe(Effect.andThen(prepared.acp.drainEvents)), | ||
| 1088 | ), | ||
| 813 | Effect.mapError((error) => | 1089 | Effect.mapError((error) => |
| 814 | mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), | 1090 | mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), |
| 815 | ), | 1091 | ), |
| ... | @@ -820,38 +1096,88 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -820,38 +1096,88 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 820 | Effect.gen(function* () { | 1096 | Effect.gen(function* () { |
| 821 | const ctx = yield* requireSession(input.threadId); | 1097 | const ctx = yield* requireSession(input.threadId); |
| 822 | if (ctx.acpSessionId !== prepared.acpSessionId) { | 1098 | if (ctx.acpSessionId !== prepared.acpSessionId) { |
| 1099 | yield* settlePromptInFlight( | ||
| 1100 | input.threadId, | ||
| 1101 | prepared.turnId, | ||
| 1102 | prepared.acpSessionId, | ||
| 1103 | { | ||
| 1104 | errorMessage: "Grok session changed before the turn completed.", | ||
| 1105 | settleAllPrompts: true, | ||
| 1106 | }, | ||
| 1107 | ); | ||
| 1108 | yield* Ref.set(promptSettled, true); | ||
| 823 | return yield* new ProviderAdapterRequestError({ | 1109 | return yield* new ProviderAdapterRequestError({ |
| 824 | provider: PROVIDER, | 1110 | provider: PROVIDER, |
| 825 | method: "session/prompt", | 1111 | method: "session/prompt", |
| 826 | detail: "Grok session changed before the turn completed.", | 1112 | detail: "Grok session changed before the turn completed.", |
| 827 | }); | 1113 | }); |
| 828 | } | 1114 | } |
| 1115 | // Keep prompt settlement atomic with respect to Stop and steering. | ||
| 1116 | // interruptTurn marks its target before waiting for this lock, so | ||
| 1117 | // cancellation can still win while queued ACP events are drained. | ||
| 1118 | for (let yieldAttempt = 0; yieldAttempt < 8; yieldAttempt += 1) { | ||
| 1119 | yield* Effect.yieldNow; | ||
| 1120 | } | ||
| 1121 | yield* prepared.acp.drainEvents; | ||
| 1122 | if (ctx.interruptedTurnIds.has(prepared.turnId)) { | ||
| 1123 | yield* Ref.set(promptSettled, true); | ||
| 1124 | return { | ||
| 1125 | threadId: input.threadId, | ||
| 1126 | turnId: prepared.turnId, | ||
| 1127 | resumeCursor: ctx.session.resumeCursor, | ||
| 1128 | }; | ||
| 1129 | } | ||
| 829 | 1130 | ||
| 830 | const existingTurnRecord = ctx.turns.find((turn) => turn.id === prepared.turnId); | 1131 | if ( |
| 831 | ctx.turns = existingTurnRecord | 1132 | ctx.promptsInFlight <= 0 || |
| 832 | ? ctx.turns.map((turn) => | 1133 | ctx.activeTurnId !== prepared.turnId || |
| 833 | turn.id === prepared.turnId | 1134 | ctx.session.activeTurnId !== prepared.turnId |
| 834 | ? { | 1135 | ) { |
| 835 | ...turn, | 1136 | yield* Ref.set(promptSettled, true); |
| 836 | items: [...turn.items, { prompt: prepared.promptParts, result }], | 1137 | return { |
| 837 | } | 1138 | threadId: input.threadId, |
| 838 | : turn, | 1139 | turnId: prepared.turnId, |
| 839 | ) | 1140 | resumeCursor: ctx.session.resumeCursor, |
| 840 | : [ | 1141 | }; |
| 841 | ...ctx.turns, | 1142 | } |
| 842 | { id: prepared.turnId, items: [{ prompt: prepared.promptParts, result }] }, | 1143 | |
| 843 | ]; | 1144 | appendPromptResultToTurn(ctx, prepared.turnId, prepared.promptParts, result); |
| 844 | ctx.session = { | 1145 | ctx.session = { |
| 845 | ...ctx.session, | 1146 | ...ctx.session, |
| 1147 | status: "running", | ||
| 846 | activeTurnId: prepared.turnId, | 1148 | activeTurnId: prepared.turnId, |
| 847 | updatedAt: yield* nowIso, | 1149 | updatedAt: yield* nowIso, |
| 848 | ...(prepared.displayModel ? { model: prepared.displayModel } : {}), | 1150 | ...(prepared.displayModel ? { model: prepared.displayModel } : {}), |
| 849 | }; | 1151 | }; |
| 1152 | const remainingPrompts = Math.max(0, ctx.promptsInFlight - 1); | ||
| 1153 | ctx.promptsInFlight = remainingPrompts; | ||
| 850 | 1154 | ||
| 851 | // Only the last remaining prompt settles the turn — a steer- | 1155 | // Only the last remaining prompt settles the turn. A steer- |
| 852 | // superseded prompt resolving (usually cancelled) while another | 1156 | // superseded prompt resolving while another is in flight or |
| 853 | // is in flight or pending must leave the merged turn running. | 1157 | // pending must leave the merged turn running. |
| 854 | if (ctx.promptsInFlight === 1) { | 1158 | if ( |
| 1159 | remainingPrompts === 0 && | ||
| 1160 | ctx.activeTurnId === prepared.turnId && | ||
| 1161 | ctx.session.activeTurnId === prepared.turnId | ||
| 1162 | ) { | ||
| 1163 | if (ctx.interruptedTurnIds.has(prepared.turnId)) { | ||
| 1164 | yield* Ref.set(promptSettled, true); | ||
| 1165 | return { | ||
| 1166 | threadId: input.threadId, | ||
| 1167 | turnId: prepared.turnId, | ||
| 1168 | resumeCursor: ctx.session.resumeCursor, | ||
| 1169 | }; | ||
| 1170 | } | ||
| 1171 | const completedAt = yield* nowIso; | ||
| 1172 | const { activeTurnId: _completedTurnId, ...readySession } = ctx.session; | ||
| 1173 | ctx.activeTurnId = undefined; | ||
| 1174 | ctx.session = { | ||
| 1175 | ...readySession, | ||
| 1176 | status: "ready", | ||
| 1177 | updatedAt: completedAt, | ||
| 1178 | ...(prepared.displayModel ? { model: prepared.displayModel } : {}), | ||
| 1179 | }; | ||
| 1180 | const completedStopReason = completedStopReasonFromPromptResponse(result); | ||
| 855 | yield* offerRuntimeEvent({ | 1181 | yield* offerRuntimeEvent({ |
| 856 | type: "turn.completed", | 1182 | type: "turn.completed", |
| 857 | ...(yield* makeEventStamp()), | 1183 | ...(yield* makeEventStamp()), |
| ... | @@ -860,9 +1186,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -860,9 +1186,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 860 | turnId: prepared.turnId, | 1186 | turnId: prepared.turnId, |
| 861 | payload: { | 1187 | payload: { |
| 862 | state: result.stopReason === "cancelled" ? "cancelled" : "completed", | 1188 | state: result.stopReason === "cancelled" ? "cancelled" : "completed", |
| 863 | stopReason: result.stopReason ?? null, | 1189 | stopReason: completedStopReason, |
| 864 | }, | 1190 | }, |
| 865 | }); | 1191 | }); |
| 1192 | ctx.interruptedTurnIds.delete(prepared.turnId); | ||
| 1193 | yield* Ref.set(promptSettled, true); | ||
| 1194 | } else if (remainingPrompts > 0) { | ||
| 1195 | yield* Ref.set(promptSettled, true); | ||
| 866 | } | 1196 | } |
| 867 | 1197 | ||
| 868 | return { | 1198 | return { |
| ... | @@ -874,27 +1204,153 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -874,27 +1204,153 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 874 | ); | 1204 | ); |
| 875 | }).pipe( | 1205 | }).pipe( |
| 876 | Effect.ensuring( | 1206 | Effect.ensuring( |
| 877 | Effect.sync(() => { | 1207 | Effect.gen(function* () { |
| 878 | const liveCtx = sessions.get(input.threadId); | 1208 | if (yield* Ref.get(promptSettled)) { |
| 879 | if (liveCtx) { | 1209 | return; |
| 880 | liveCtx.promptsInFlight = Math.max(0, liveCtx.promptsInFlight - 1); | ||
| 881 | } | 1210 | } |
| 882 | }), | 1211 | |
| 1212 | if (yield* Ref.get(promptRpcSucceeded)) { | ||
| 1213 | const promptResult = yield* Ref.get(promptResultRef); | ||
| 1214 | if (promptResult === undefined) { | ||
| 1215 | return; | ||
| 1216 | } | ||
| 1217 | yield* withThreadLock( | ||
| 1218 | input.threadId, | ||
| 1219 | Effect.gen(function* () { | ||
| 1220 | const ctx = yield* requireSession(input.threadId); | ||
| 1221 | if (ctx.acpSessionId !== prepared.acpSessionId) { | ||
| 1222 | yield* settlePromptInFlight( | ||
| 1223 | input.threadId, | ||
| 1224 | prepared.turnId, | ||
| 1225 | prepared.acpSessionId, | ||
| 1226 | { | ||
| 1227 | errorMessage: "Grok session changed before the turn completed.", | ||
| 1228 | settleAllPrompts: true, | ||
| 1229 | }, | ||
| 1230 | ); | ||
| 1231 | return; | ||
| 1232 | } | ||
| 1233 | if (ctx.interruptedTurnIds.has(prepared.turnId)) { | ||
| 1234 | return; | ||
| 1235 | } | ||
| 1236 | if ( | ||
| 1237 | ctx.promptsInFlight <= 0 || | ||
| 1238 | ctx.activeTurnId !== prepared.turnId || | ||
| 1239 | ctx.session.activeTurnId !== prepared.turnId | ||
| 1240 | ) { | ||
| 1241 | return; | ||
| 1242 | } | ||
| 1243 | appendPromptResultToTurn( | ||
| 1244 | ctx, | ||
| 1245 | prepared.turnId, | ||
| 1246 | prepared.promptParts, | ||
| 1247 | promptResult, | ||
| 1248 | ); | ||
| 1249 | yield* settlePromptInFlight( | ||
| 1250 | input.threadId, | ||
| 1251 | prepared.turnId, | ||
| 1252 | prepared.acpSessionId, | ||
| 1253 | { | ||
| 1254 | completedStopReason: completedStopReasonFromPromptResponse(promptResult), | ||
| 1255 | }, | ||
| 1256 | ); | ||
| 1257 | }), | ||
| 1258 | ); | ||
| 1259 | return; | ||
| 1260 | } | ||
| 1261 | |||
| 1262 | const errorMessage = yield* Ref.get(promptFailureMessageRef); | ||
| 1263 | yield* withThreadLock( | ||
| 1264 | input.threadId, | ||
| 1265 | settlePromptInFlight(input.threadId, prepared.turnId, prepared.acpSessionId, { | ||
| 1266 | errorMessage: errorMessage ?? "Grok prompt request failed.", | ||
| 1267 | }), | ||
| 1268 | ); | ||
| 1269 | }).pipe(Effect.catch(() => Effect.void)), | ||
| 883 | ), | 1270 | ), |
| 884 | ); | 1271 | ); |
| 885 | }); | 1272 | }); |
| 886 | 1273 | ||
| 887 | const interruptTurn: GrokAdapterShape["interruptTurn"] = (threadId) => | 1274 | const interruptTurn: GrokAdapterShape["interruptTurn"] = (threadId, turnId) => |
| 888 | Effect.gen(function* () { | 1275 | Effect.gen(function* () { |
| 889 | const ctx = yield* requireSession(threadId); | 1276 | const observed = yield* Effect.sync(() => { |
| 890 | yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); | 1277 | const ctx = sessions.get(threadId); |
| 891 | yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); | 1278 | if (!ctx || ctx.stopped) { |
| 892 | yield* Effect.ignore( | 1279 | return { |
| 893 | ctx.acp.cancel.pipe( | 1280 | _tag: "Proceed" as const, |
| 894 | Effect.mapError((error) => | 1281 | acpSessionId: undefined, |
| 895 | mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), | 1282 | interruptedTurnId: turnId, |
| 896 | ), | 1283 | }; |
| 897 | ), | 1284 | } |
| 1285 | const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; | ||
| 1286 | if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { | ||
| 1287 | return { _tag: "Ignore" as const }; | ||
| 1288 | } | ||
| 1289 | const interruptedTurnId = turnId ?? activeTurnId; | ||
| 1290 | if (interruptedTurnId !== undefined) { | ||
| 1291 | ctx.interruptedTurnIds.add(interruptedTurnId); | ||
| 1292 | } | ||
| 1293 | return { | ||
| 1294 | _tag: "Proceed" as const, | ||
| 1295 | acpSessionId: ctx.acpSessionId, | ||
| 1296 | interruptedTurnId, | ||
| 1297 | }; | ||
| 1298 | }); | ||
| 1299 | if (observed._tag === "Ignore") { | ||
| 1300 | return; | ||
| 1301 | } | ||
| 1302 | |||
| 1303 | yield* withThreadLock( | ||
| 1304 | threadId, | ||
| 1305 | Effect.gen(function* () { | ||
| 1306 | const ctx = yield* requireSession(threadId); | ||
| 1307 | if (observed.acpSessionId !== undefined && ctx.acpSessionId !== observed.acpSessionId) { | ||
| 1308 | return; | ||
| 1309 | } | ||
| 1310 | const activeTurnId = ctx.activeTurnId ?? ctx.session.activeTurnId; | ||
| 1311 | if (turnId !== undefined && activeTurnId !== undefined && activeTurnId !== turnId) { | ||
| 1312 | return; | ||
| 1313 | } | ||
| 1314 | if ( | ||
| 1315 | observed.interruptedTurnId !== undefined && | ||
| 1316 | activeTurnId !== undefined && | ||
| 1317 | activeTurnId !== observed.interruptedTurnId | ||
| 1318 | ) { | ||
| 1319 | return; | ||
| 1320 | } | ||
| 1321 | const interruptedTurnId = | ||
| 1322 | observed.interruptedTurnId ?? turnId ?? activeTurnId ?? ctx.session.activeTurnId; | ||
| 1323 | yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); | ||
| 1324 | yield* settlePendingUserInputsAsCancelled(ctx.pendingUserInputs); | ||
| 1325 | yield* Effect.ignore( | ||
| 1326 | ctx.acp.cancel.pipe( | ||
| 1327 | Effect.mapError((error) => | ||
| 1328 | mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), | ||
| 1329 | ), | ||
| 1330 | ), | ||
| 1331 | ); | ||
| 1332 | if (interruptedTurnId) { | ||
| 1333 | ctx.interruptedTurnIds.add(interruptedTurnId); | ||
| 1334 | yield* settlePromptInFlight(threadId, interruptedTurnId, ctx.acpSessionId, { | ||
| 1335 | completedStopReason: "cancelled", | ||
| 1336 | settleAllPrompts: true, | ||
| 1337 | }); | ||
| 1338 | } else if ( | ||
| 1339 | ctx.promptsInFlight > 0 || | ||
| 1340 | ctx.session.status === "running" || | ||
| 1341 | ctx.session.status === "connecting" | ||
| 1342 | ) { | ||
| 1343 | const updatedAt = yield* nowIso; | ||
| 1344 | ctx.promptsInFlight = 0; | ||
| 1345 | ctx.activeTurnId = undefined; | ||
| 1346 | const { activeTurnId: _activeTurnId, ...readySession } = ctx.session; | ||
| 1347 | ctx.session = { | ||
| 1348 | ...readySession, | ||
| 1349 | status: "ready", | ||
| 1350 | updatedAt, | ||
| 1351 | }; | ||
| 1352 | } | ||
| 1353 | }), | ||
| 898 | ); | 1354 | ); |
| 899 | }); | 1355 | }); |
| 900 | 1356 |
apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts+222| ... | @@ -7,6 +7,9 @@ import * as NodeFS from "node:fs"; | ... | @@ -7,6 +7,9 @@ import * as NodeFS from "node:fs"; |
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 8 | import { it } from "@effect/vitest"; | 8 | import { it } from "@effect/vitest"; |
| 9 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Fiber from "effect/Fiber"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | import * as TestClock from "effect/testing/TestClock"; | ||
| 10 | import * as Stream from "effect/Stream"; | 13 | import * as Stream from "effect/Stream"; |
| 11 | import { describe, expect } from "vite-plus/test"; | 14 | import { describe, expect } from "vite-plus/test"; |
| 12 | 15 | ||
| ... | @@ -113,6 +116,122 @@ describe("AcpSessionRuntime", () => { | ... | @@ -113,6 +116,122 @@ describe("AcpSessionRuntime", () => { |
| 113 | ), | 116 | ), |
| 114 | ); | 117 | ); |
| 115 | 118 | ||
| 119 | it.effect("drops session updates emitted for a child ACP session", () => | ||
| 120 | Effect.gen(function* () { | ||
| 121 | const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; | ||
| 122 | yield* runtime.start(); | ||
| 123 | |||
| 124 | const promptResult = yield* runtime.prompt({ | ||
| 125 | prompt: [{ type: "text", text: "hi" }], | ||
| 126 | }); | ||
| 127 | expect(promptResult).toMatchObject({ stopReason: "end_turn" }); | ||
| 128 | |||
| 129 | const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4))); | ||
| 130 | expect(notes.map((note) => note._tag)).toEqual([ | ||
| 131 | "AssistantItemStarted", | ||
| 132 | "ContentDelta", | ||
| 133 | "ContentDelta", | ||
| 134 | "AssistantItemCompleted", | ||
| 135 | ]); | ||
| 136 | expect( | ||
| 137 | notes | ||
| 138 | .filter((note) => note._tag === "ContentDelta") | ||
| 139 | .map((note) => note.text) | ||
| 140 | .join(""), | ||
| 141 | ).toBe("root before child root after child"); | ||
| 142 | expect(notes.some((note) => note._tag === "ToolCallUpdated")).toBe(false); | ||
| 143 | }).pipe( | ||
| 144 | Effect.provide( | ||
| 145 | AcpSessionRuntime.layer({ | ||
| 146 | spawn: { | ||
| 147 | command: mockAgentCommand, | ||
| 148 | args: mockAgentArgs, | ||
| 149 | env: { | ||
| 150 | T3_ACP_EMIT_FOREIGN_SESSION_UPDATES: "1", | ||
| 151 | }, | ||
| 152 | }, | ||
| 153 | cwd: process.cwd(), | ||
| 154 | clientInfo: { name: "t3-test", version: "0.0.0" }, | ||
| 155 | authMethodId: "test", | ||
| 156 | }), | ||
| 157 | ), | ||
| 158 | Effect.scoped, | ||
| 159 | Effect.provide(NodeServices.layer), | ||
| 160 | ), | ||
| 161 | ); | ||
| 162 | |||
| 163 | it.effect("supports successive standard ACP prompts", () => | ||
| 164 | Effect.gen(function* () { | ||
| 165 | const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; | ||
| 166 | yield* runtime.start(); | ||
| 167 | |||
| 168 | const firstPromptResult = yield* runtime.prompt({ | ||
| 169 | prompt: [{ type: "text", text: "first" }], | ||
| 170 | }); | ||
| 171 | const secondPromptResult = yield* runtime.prompt({ | ||
| 172 | prompt: [{ type: "text", text: "second" }], | ||
| 173 | }); | ||
| 174 | |||
| 175 | expect(firstPromptResult).toMatchObject({ stopReason: "end_turn" }); | ||
| 176 | expect(secondPromptResult).toMatchObject({ stopReason: "end_turn" }); | ||
| 177 | }).pipe( | ||
| 178 | Effect.provide( | ||
| 179 | AcpSessionRuntime.layer({ | ||
| 180 | spawn: { | ||
| 181 | command: mockAgentCommand, | ||
| 182 | args: mockAgentArgs, | ||
| 183 | }, | ||
| 184 | cwd: process.cwd(), | ||
| 185 | clientInfo: { name: "t3-test", version: "0.0.0" }, | ||
| 186 | authMethodId: "test", | ||
| 187 | }), | ||
| 188 | ), | ||
| 189 | Effect.scoped, | ||
| 190 | Effect.provide(NodeServices.layer), | ||
| 191 | ), | ||
| 192 | ); | ||
| 193 | |||
| 194 | it.effect("releases a fully silent prompt when session/cancel is requested", () => | ||
| 195 | Effect.gen(function* () { | ||
| 196 | const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; | ||
| 197 | yield* runtime.start(); | ||
| 198 | |||
| 199 | const promptFiber = yield* runtime | ||
| 200 | .prompt({ | ||
| 201 | prompt: [{ type: "text", text: "hang forever" }], | ||
| 202 | }) | ||
| 203 | .pipe(Effect.forkChild({ startImmediately: true })); | ||
| 204 | |||
| 205 | yield* TestClock.adjust("500 millis"); | ||
| 206 | yield* runtime.cancel; | ||
| 207 | |||
| 208 | const firstPromptResult = yield* Fiber.join(promptFiber); | ||
| 209 | expect(firstPromptResult).toMatchObject({ stopReason: "cancelled" }); | ||
| 210 | |||
| 211 | const secondPromptResult = yield* runtime.prompt({ | ||
| 212 | prompt: [{ type: "text", text: "second" }], | ||
| 213 | }); | ||
| 214 | expect(secondPromptResult).toMatchObject({ stopReason: "end_turn" }); | ||
| 215 | }).pipe( | ||
| 216 | Effect.provide( | ||
| 217 | AcpSessionRuntime.layer({ | ||
| 218 | spawn: { | ||
| 219 | command: mockAgentCommand, | ||
| 220 | args: mockAgentArgs, | ||
| 221 | env: { | ||
| 222 | T3_ACP_HANG_FIRST_PROMPT_FOREVER: "1", | ||
| 223 | }, | ||
| 224 | }, | ||
| 225 | cwd: process.cwd(), | ||
| 226 | clientInfo: { name: "t3-test", version: "0.0.0" }, | ||
| 227 | authMethodId: "test", | ||
| 228 | }), | ||
| 229 | ), | ||
| 230 | Effect.scoped, | ||
| 231 | Effect.provide(NodeServices.layer), | ||
| 232 | ), | ||
| 233 | ); | ||
| 234 | |||
| 116 | it.effect("segments assistant text around ACP tool calls", () => | 235 | it.effect("segments assistant text around ACP tool calls", () => |
| 117 | Effect.gen(function* () { | 236 | Effect.gen(function* () { |
| 118 | const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; | 237 | const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; |
| ... | @@ -346,6 +465,109 @@ describe("AcpSessionRuntime", () => { | ... | @@ -346,6 +465,109 @@ describe("AcpSessionRuntime", () => { |
| 346 | ); | 465 | ); |
| 347 | }); | 466 | }); |
| 348 | 467 | ||
| 468 | it.effect("fails session startup when session/load returns an error", () => | ||
| 469 | Effect.gen(function* () { | ||
| 470 | const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; | ||
| 471 | const error = yield* runtime.start().pipe(Effect.flip); | ||
| 472 | |||
| 473 | expect(error._tag).toBe("AcpRequestError"); | ||
| 474 | }).pipe( | ||
| 475 | Effect.provide( | ||
| 476 | AcpSessionRuntime.layer({ | ||
| 477 | authMethodId: "test", | ||
| 478 | spawn: { | ||
| 479 | command: mockAgentCommand, | ||
| 480 | args: mockAgentArgs, | ||
| 481 | env: { | ||
| 482 | T3_ACP_FAIL_LOAD_SESSION: "1", | ||
| 483 | }, | ||
| 484 | }, | ||
| 485 | cwd: process.cwd(), | ||
| 486 | resumeSessionId: "stale-session-id", | ||
| 487 | clientInfo: { name: "t3-test", version: "0.0.0" }, | ||
| 488 | }), | ||
| 489 | ), | ||
| 490 | Effect.scoped, | ||
| 491 | Effect.provide(NodeServices.layer), | ||
| 492 | ), | ||
| 493 | ); | ||
| 494 | |||
| 495 | it.effect("ignores session/update replay notifications during session/load", () => | ||
| 496 | Effect.gen(function* () { | ||
| 497 | const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; | ||
| 498 | yield* runtime.start(); | ||
| 499 | |||
| 500 | yield* runtime.prompt({ | ||
| 501 | prompt: [{ type: "text", text: "hi" }], | ||
| 502 | }); | ||
| 503 | const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4))); | ||
| 504 | expect(notes.map((note) => note._tag)).toEqual([ | ||
| 505 | "PlanUpdated", | ||
| 506 | "AssistantItemStarted", | ||
| 507 | "ContentDelta", | ||
| 508 | "AssistantItemCompleted", | ||
| 509 | ]); | ||
| 510 | expect(notes.some((note) => note._tag === "ToolCallUpdated")).toBe(false); | ||
| 511 | }).pipe( | ||
| 512 | Effect.provide( | ||
| 513 | AcpSessionRuntime.layer({ | ||
| 514 | authMethodId: "test", | ||
| 515 | spawn: { | ||
| 516 | command: mockAgentCommand, | ||
| 517 | args: mockAgentArgs, | ||
| 518 | env: { | ||
| 519 | T3_ACP_EMIT_LOAD_REPLAY: "1", | ||
| 520 | }, | ||
| 521 | }, | ||
| 522 | cwd: process.cwd(), | ||
| 523 | resumeSessionId: "mock-session-1", | ||
| 524 | clientInfo: { name: "t3-test", version: "0.0.0" }, | ||
| 525 | }), | ||
| 526 | ), | ||
| 527 | Effect.scoped, | ||
| 528 | Effect.provide(NodeServices.layer), | ||
| 529 | ), | ||
| 530 | ); | ||
| 531 | |||
| 532 | it.effect("completes session/load after replay becomes idle while its RPC stays pending", () => | ||
| 533 | Effect.gen(function* () { | ||
| 534 | const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; | ||
| 535 | const started = yield* runtime.start().pipe(Effect.timeout("2 seconds")); | ||
| 536 | |||
| 537 | expect(started.sessionId).toBe("mock-session-1"); | ||
| 538 | expect(started.sessionSetupResult._meta).toMatchObject({ | ||
| 539 | t3SessionLoadReady: "replay_idle", | ||
| 540 | }); | ||
| 541 | |||
| 542 | const unexpectedReplayEvent = yield* Stream.runHead(runtime.getEvents()).pipe( | ||
| 543 | Effect.timeoutOption("100 millis"), | ||
| 544 | ); | ||
| 545 | expect(Option.isNone(unexpectedReplayEvent)).toBe(true); | ||
| 546 | }).pipe( | ||
| 547 | Effect.provide( | ||
| 548 | AcpSessionRuntime.layer({ | ||
| 549 | authMethodId: "test", | ||
| 550 | spawn: { | ||
| 551 | command: mockAgentCommand, | ||
| 552 | args: mockAgentArgs, | ||
| 553 | env: { | ||
| 554 | T3_ACP_HANG_LOAD_SESSION_AFTER_REPLAY: "1", | ||
| 555 | T3_ACP_LOAD_SESSION_DELAY_MS: "10000", | ||
| 556 | }, | ||
| 557 | }, | ||
| 558 | cwd: process.cwd(), | ||
| 559 | resumeSessionId: "mock-session-1", | ||
| 560 | sessionLoadReplayIdleGap: "50 millis", | ||
| 561 | sessionLoadTimeout: "1 second", | ||
| 562 | clientInfo: { name: "t3-test", version: "0.0.0" }, | ||
| 563 | }), | ||
| 564 | ), | ||
| 565 | Effect.scoped, | ||
| 566 | Effect.provide(NodeServices.layer), | ||
| 567 | TestClock.withLive, | ||
| 568 | ), | ||
| 569 | ); | ||
| 570 | |||
| 349 | it.effect("rejects invalid config option values before sending session/set_config_option", () => { | 571 | it.effect("rejects invalid config option values before sending session/set_config_option", () => { |
| 350 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-")); | 572 | const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "acp-runtime-")); |
| 351 | const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); | 573 | const requestLogPath = NodePath.join(tempDir, "requests.ndjson"); |
apps/server/src/provider/acp/AcpRuntimeModel.test.ts+91| ... | @@ -8,6 +8,8 @@ import { | ... | @@ -8,6 +8,8 @@ import { |
| 8 | parsePermissionRequest, | 8 | parsePermissionRequest, |
| 9 | parseSessionModeState, | 9 | parseSessionModeState, |
| 10 | parseSessionUpdateEvent, | 10 | parseSessionUpdateEvent, |
| 11 | sessionUpdateIsReplay, | ||
| 12 | syntheticLoadSessionResponseFromInitialize, | ||
| 11 | } from "./AcpRuntimeModel.ts"; | 13 | } from "./AcpRuntimeModel.ts"; |
| 12 | 14 | ||
| 13 | describe("AcpRuntimeModel", () => { | 15 | describe("AcpRuntimeModel", () => { |
| ... | @@ -59,6 +61,95 @@ describe("AcpRuntimeModel", () => { | ... | @@ -59,6 +61,95 @@ describe("AcpRuntimeModel", () => { |
| 59 | expect(modelConfigId).toBe("model"); | 61 | expect(modelConfigId).toBe("model"); |
| 60 | }); | 62 | }); |
| 61 | 63 | ||
| 64 | it("detects Grok session replay updates from _meta.isReplay", () => { | ||
| 65 | expect( | ||
| 66 | sessionUpdateIsReplay({ | ||
| 67 | _meta: { isReplay: true }, | ||
| 68 | sessionId: "session-1", | ||
| 69 | update: { | ||
| 70 | sessionUpdate: "agent_message_chunk", | ||
| 71 | content: { type: "text", text: "replayed" }, | ||
| 72 | }, | ||
| 73 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 74 | ).toBe(true); | ||
| 75 | expect( | ||
| 76 | sessionUpdateIsReplay({ | ||
| 77 | sessionId: "session-1", | ||
| 78 | update: { | ||
| 79 | sessionUpdate: "agent_message_chunk", | ||
| 80 | content: { type: "text", text: "live" }, | ||
| 81 | }, | ||
| 82 | } satisfies EffectAcpSchema.SessionNotification), | ||
| 83 | ).toBe(false); | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("builds a synthetic load response from initialize model state", () => { | ||
| 87 | const response = syntheticLoadSessionResponseFromInitialize({ | ||
| 88 | protocolVersion: 1, | ||
| 89 | _meta: { | ||
| 90 | modelState: { | ||
| 91 | currentModelId: "grok-build", | ||
| 92 | availableModels: [{ modelId: "grok-build", name: "Grok Build" }], | ||
| 93 | }, | ||
| 94 | }, | ||
| 95 | } satisfies EffectAcpSchema.InitializeResponse); | ||
| 96 | |||
| 97 | expect(response.models?.currentModelId).toBe("grok-build"); | ||
| 98 | expect(response._meta).toMatchObject({ t3SessionLoadReady: "replay_idle" }); | ||
| 99 | }); | ||
| 100 | |||
| 101 | it("accepts initialize model descriptions with null", () => { | ||
| 102 | const response = syntheticLoadSessionResponseFromInitialize({ | ||
| 103 | protocolVersion: 1, | ||
| 104 | _meta: { | ||
| 105 | modelState: { | ||
| 106 | currentModelId: "grok-build", | ||
| 107 | availableModels: [{ modelId: "grok-build", name: "Grok Build", description: null }], | ||
| 108 | }, | ||
| 109 | }, | ||
| 110 | } satisfies EffectAcpSchema.InitializeResponse); | ||
| 111 | |||
| 112 | expect(response.models?.availableModels[0]?.description).toBeNull(); | ||
| 113 | }); | ||
| 114 | |||
| 115 | it("ignores malformed initialize model state in synthetic load responses", () => { | ||
| 116 | const response = syntheticLoadSessionResponseFromInitialize({ | ||
| 117 | protocolVersion: 1, | ||
| 118 | _meta: { | ||
| 119 | modelState: { | ||
| 120 | currentModelId: "grok-build", | ||
| 121 | availableModels: [null], | ||
| 122 | }, | ||
| 123 | modeState: { | ||
| 124 | currentModeId: "code", | ||
| 125 | availableModes: [{ id: "code", name: 12 }], | ||
| 126 | }, | ||
| 127 | }, | ||
| 128 | } as EffectAcpSchema.InitializeResponse); | ||
| 129 | |||
| 130 | expect(response.models).toBeUndefined(); | ||
| 131 | expect(response.modes).toBeUndefined(); | ||
| 132 | expect(response._meta).toMatchObject({ t3SessionLoadReady: "replay_idle" }); | ||
| 133 | }); | ||
| 134 | |||
| 135 | it("builds a synthetic load response with initialize mode state", () => { | ||
| 136 | const response = syntheticLoadSessionResponseFromInitialize({ | ||
| 137 | protocolVersion: 1, | ||
| 138 | _meta: { | ||
| 139 | modeState: { | ||
| 140 | currentModeId: "code", | ||
| 141 | availableModes: [ | ||
| 142 | { id: "ask", name: "Ask" }, | ||
| 143 | { id: "code", name: "Code" }, | ||
| 144 | ], | ||
| 145 | }, | ||
| 146 | }, | ||
| 147 | } satisfies EffectAcpSchema.InitializeResponse); | ||
| 148 | |||
| 149 | expect(response.modes?.currentModeId).toBe("code"); | ||
| 150 | expect(response.modes?.availableModes).toHaveLength(2); | ||
| 151 | }); | ||
| 152 | |||
| 62 | it("projects typed ACP tool call updates into runtime events", () => { | 153 | it("projects typed ACP tool call updates into runtime events", () => { |
| 63 | const created = parseSessionUpdateEvent({ | 154 | const created = parseSessionUpdateEvent({ |
| 64 | sessionId: "session-1", | 155 | sessionId: "session-1", |
apps/server/src/provider/acp/AcpRuntimeModel.ts+91| ... | @@ -1,3 +1,8 @@ | ... | @@ -1,3 +1,8 @@ |
| 1 | import * as Clock from "effect/Clock"; | ||
| 2 | import * as Duration from "effect/Duration"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Option from "effect/Option"; | ||
| 5 | import * as Ref from "effect/Ref"; | ||
| 1 | import type * as EffectAcpSchema from "effect-acp/schema"; | 6 | import type * as EffectAcpSchema from "effect-acp/schema"; |
| 2 | import { deriveToolActivityPresentation } from "@t3tools/shared/toolActivity"; | 7 | import { deriveToolActivityPresentation } from "@t3tools/shared/toolActivity"; |
| 3 | import type { ToolLifecycleItemType } from "@t3tools/contracts"; | 8 | import type { ToolLifecycleItemType } from "@t3tools/contracts"; |
| ... | @@ -6,6 +11,40 @@ function isRecord(value: unknown): value is Record<string, unknown> { | ... | @@ -6,6 +11,40 @@ function isRecord(value: unknown): value is Record<string, unknown> { |
| 6 | return typeof value === "object" && value !== null && !Array.isArray(value); | 11 | return typeof value === "object" && value !== null && !Array.isArray(value); |
| 7 | } | 12 | } |
| 8 | 13 | ||
| 14 | function isSessionModelState(value: unknown): value is EffectAcpSchema.SessionModelState { | ||
| 15 | if (!isRecord(value) || typeof value.currentModelId !== "string") { | ||
| 16 | return false; | ||
| 17 | } | ||
| 18 | if (!Array.isArray(value.availableModels)) { | ||
| 19 | return false; | ||
| 20 | } | ||
| 21 | return value.availableModels.every( | ||
| 22 | (model) => | ||
| 23 | isRecord(model) && | ||
| 24 | typeof model.modelId === "string" && | ||
| 25 | typeof model.name === "string" && | ||
| 26 | (model.description === undefined || | ||
| 27 | model.description === null || | ||
| 28 | typeof model.description === "string"), | ||
| 29 | ); | ||
| 30 | } | ||
| 31 | |||
| 32 | function isSessionModeState(value: unknown): value is EffectAcpSchema.SessionModeState { | ||
| 33 | if (!isRecord(value) || typeof value.currentModeId !== "string") { | ||
| 34 | return false; | ||
| 35 | } | ||
| 36 | if (!Array.isArray(value.availableModes)) { | ||
| 37 | return false; | ||
| 38 | } | ||
| 39 | return value.availableModes.every( | ||
| 40 | (mode) => | ||
| 41 | isRecord(mode) && | ||
| 42 | typeof mode.id === "string" && | ||
| 43 | typeof mode.name === "string" && | ||
| 44 | (mode.description === undefined || typeof mode.description === "string"), | ||
| 45 | ); | ||
| 46 | } | ||
| 47 | |||
| 9 | export interface AcpSessionMode { | 48 | export interface AcpSessionMode { |
| 10 | readonly id: string; | 49 | readonly id: string; |
| 11 | readonly name: string; | 50 | readonly name: string; |
| ... | @@ -414,6 +453,58 @@ export function parsePermissionRequest( | ... | @@ -414,6 +453,58 @@ export function parsePermissionRequest( |
| 414 | }; | 453 | }; |
| 415 | } | 454 | } |
| 416 | 455 | ||
| 456 | export function sessionUpdateIsReplay(params: EffectAcpSchema.SessionNotification): boolean { | ||
| 457 | const meta = params._meta; | ||
| 458 | return isRecord(meta) && meta.isReplay === true; | ||
| 459 | } | ||
| 460 | |||
| 461 | export interface SessionLoadGate { | ||
| 462 | readonly active: boolean; | ||
| 463 | readonly lastActivityAtMillis: number | undefined; | ||
| 464 | readonly idleGap: Duration.Duration; | ||
| 465 | readonly initializeResult: EffectAcpSchema.InitializeResponse; | ||
| 466 | } | ||
| 467 | |||
| 468 | export const waitForSessionLoadReplayIdle = (input: { | ||
| 469 | readonly gateRef: Ref.Ref<Option.Option<SessionLoadGate>>; | ||
| 470 | }): Effect.Effect<EffectAcpSchema.LoadSessionResponse, never> => | ||
| 471 | Effect.gen(function* () { | ||
| 472 | const pollInterval = Duration.millis(25); | ||
| 473 | while (true) { | ||
| 474 | const gate = yield* Ref.get(input.gateRef); | ||
| 475 | if ( | ||
| 476 | Option.isSome(gate) && | ||
| 477 | gate.value.active && | ||
| 478 | gate.value.lastActivityAtMillis !== undefined | ||
| 479 | ) { | ||
| 480 | const idleGapMillis = Duration.toMillis(gate.value.idleGap); | ||
| 481 | const nowMillis = yield* Clock.currentTimeMillis; | ||
| 482 | if (nowMillis - gate.value.lastActivityAtMillis >= idleGapMillis) { | ||
| 483 | return syntheticLoadSessionResponseFromInitialize(gate.value.initializeResult); | ||
| 484 | } | ||
| 485 | } | ||
| 486 | yield* Effect.sleep(pollInterval); | ||
| 487 | } | ||
| 488 | }); | ||
| 489 | |||
| 490 | export function syntheticLoadSessionResponseFromInitialize( | ||
| 491 | initializeResult: EffectAcpSchema.InitializeResponse, | ||
| 492 | ): EffectAcpSchema.LoadSessionResponse { | ||
| 493 | const meta = initializeResult._meta; | ||
| 494 | const modelState = isRecord(meta) ? meta.modelState : undefined; | ||
| 495 | const modeState = isRecord(meta) ? meta.modeState : undefined; | ||
| 496 | const models = isSessionModelState(modelState) ? modelState : undefined; | ||
| 497 | const modes = isSessionModeState(modeState) ? modeState : undefined; | ||
| 498 | |||
| 499 | return { | ||
| 500 | ...(models ? { models } : {}), | ||
| 501 | ...(modes ? { modes } : {}), | ||
| 502 | _meta: { | ||
| 503 | t3SessionLoadReady: "replay_idle", | ||
| 504 | }, | ||
| 505 | }; | ||
| 506 | } | ||
| 507 | |||
| 417 | export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): { | 508 | export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotification): { |
| 418 | readonly modeId?: string; | 509 | readonly modeId?: string; |
| 419 | readonly events: ReadonlyArray<AcpParsedSessionEvent>; | 510 | readonly events: ReadonlyArray<AcpParsedSessionEvent>; |
apps/server/src/provider/acp/AcpSessionRuntime.ts+177-46| ... | @@ -1,12 +1,16 @@ | ... | @@ -1,12 +1,16 @@ |
| 1 | import * as Cause from "effect/Cause"; | 1 | import * as Cause from "effect/Cause"; |
| 2 | import * as Clock from "effect/Clock"; | ||
| 2 | import * as Context from "effect/Context"; | 3 | import * as Context from "effect/Context"; |
| 3 | import * as Deferred from "effect/Deferred"; | 4 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Duration from "effect/Duration"; | ||
| 4 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 5 | import * as Exit from "effect/Exit"; | 7 | import * as Fiber from "effect/Fiber"; |
| 6 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as Option from "effect/Option"; | ||
| 7 | import * as Queue from "effect/Queue"; | 10 | import * as Queue from "effect/Queue"; |
| 8 | import * as Ref from "effect/Ref"; | 11 | import * as Ref from "effect/Ref"; |
| 9 | import * as Scope from "effect/Scope"; | 12 | import * as Scope from "effect/Scope"; |
| 13 | import * as Semaphore from "effect/Semaphore"; | ||
| 10 | import * as Stream from "effect/Stream"; | 14 | import * as Stream from "effect/Stream"; |
| 11 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; | 15 | import * as ChildProcess from "effect/unstable/process/ChildProcess"; |
| 12 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | 16 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; |
| ... | @@ -23,6 +27,9 @@ import { | ... | @@ -23,6 +27,9 @@ import { |
| 23 | mergeToolCallState, | 27 | mergeToolCallState, |
| 24 | parseSessionModeState, | 28 | parseSessionModeState, |
| 25 | parseSessionUpdateEvent, | 29 | parseSessionUpdateEvent, |
| 30 | sessionUpdateIsReplay, | ||
| 31 | waitForSessionLoadReplayIdle, | ||
| 32 | type SessionLoadGate, | ||
| 26 | type AcpParsedSessionEvent, | 33 | type AcpParsedSessionEvent, |
| 27 | type AcpSessionModeState, | 34 | type AcpSessionModeState, |
| 28 | type AcpToolCallState, | 35 | type AcpToolCallState, |
| ... | @@ -32,6 +39,16 @@ function formatConfigOptionValue(value: string | boolean): string { | ... | @@ -32,6 +39,16 @@ function formatConfigOptionValue(value: string | boolean): string { |
| 32 | return JSON.stringify(value); | 39 | return JSON.stringify(value); |
| 33 | } | 40 | } |
| 34 | 41 | ||
| 42 | export interface AcpSessionEventStreamBarrier { | ||
| 43 | readonly _tag: "EventStreamBarrier"; | ||
| 44 | readonly acknowledge: Deferred.Deferred<void>; | ||
| 45 | } | ||
| 46 | |||
| 47 | export type AcpSessionRuntimeEvent = AcpParsedSessionEvent | AcpSessionEventStreamBarrier; | ||
| 48 | |||
| 49 | const defaultSessionLoadTimeout = Duration.seconds(90); | ||
| 50 | const defaultSessionLoadReplayIdleGap = Duration.seconds(2); | ||
| 51 | |||
| 35 | export interface AcpSpawnInput { | 52 | export interface AcpSpawnInput { |
| 36 | readonly command: string; | 53 | readonly command: string; |
| 37 | readonly args: ReadonlyArray<string>; | 54 | readonly args: ReadonlyArray<string>; |
| ... | @@ -43,6 +60,8 @@ export interface AcpSessionRuntimeOptions { | ... | @@ -43,6 +60,8 @@ export interface AcpSessionRuntimeOptions { |
| 43 | readonly spawn: AcpSpawnInput; | 60 | readonly spawn: AcpSpawnInput; |
| 44 | readonly cwd: string; | 61 | readonly cwd: string; |
| 45 | readonly resumeSessionId?: string; | 62 | readonly resumeSessionId?: string; |
| 63 | readonly sessionLoadTimeout?: Duration.Input; | ||
| 64 | readonly sessionLoadReplayIdleGap?: Duration.Input; | ||
| 46 | readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; | 65 | readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; |
| 47 | readonly clientInfo: { | 66 | readonly clientInfo: { |
| 48 | readonly name: string; | 67 | readonly name: string; |
| ... | @@ -160,7 +179,9 @@ export class AcpSessionRuntime extends Context.Service< | ... | @@ -160,7 +179,9 @@ export class AcpSessionRuntime extends Context.Service< |
| 160 | */ | 179 | */ |
| 161 | readonly start: () => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>; | 180 | readonly start: () => Effect.Effect<AcpSessionRuntimeStartResult, EffectAcpErrors.AcpError>; |
| 162 | /** Stream of parsed ACP session events emitted after startup. */ | 181 | /** Stream of parsed ACP session events emitted after startup. */ |
| 163 | readonly getEvents: () => Stream.Stream<AcpParsedSessionEvent, never>; | 182 | readonly getEvents: () => Stream.Stream<AcpSessionRuntimeEvent, never>; |
| 183 | /** Waits until the current event consumer has processed every queued event. */ | ||
| 184 | readonly drainEvents: Effect.Effect<void>; | ||
| 164 | /** Latest mode state observed from session setup and `session/update` notifications. */ | 185 | /** Latest mode state observed from session setup and `session/update` notifications. */ |
| 165 | readonly getModeState: Effect.Effect<AcpSessionModeState | undefined>; | 186 | readonly getModeState: Effect.Effect<AcpSessionModeState | undefined>; |
| 166 | /** Latest configuration options observed from session setup and configuration writes. */ | 187 | /** Latest configuration options observed from session setup and configuration writes. */ |
| ... | @@ -254,12 +275,17 @@ export const make = ( | ... | @@ -254,12 +275,17 @@ export const make = ( |
| 254 | Effect.gen(function* () { | 275 | Effect.gen(function* () { |
| 255 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 276 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 256 | const runtimeScope = yield* Scope.Scope; | 277 | const runtimeScope = yield* Scope.Scope; |
| 257 | const eventQueue = yield* Queue.unbounded<AcpParsedSessionEvent>(); | 278 | const eventQueue = yield* Queue.unbounded<AcpSessionRuntimeEvent>(); |
| 258 | const modeStateRef = yield* Ref.make<AcpSessionModeState | undefined>(undefined); | 279 | const modeStateRef = yield* Ref.make<AcpSessionModeState | undefined>(undefined); |
| 259 | const toolCallsRef = yield* Ref.make(new Map<string, AcpToolCallState>()); | 280 | const toolCallsRef = yield* Ref.make(new Map<string, AcpToolCallState>()); |
| 260 | const assistantSegmentRef = yield* Ref.make<AcpAssistantSegmentState>({ nextSegmentIndex: 0 }); | 281 | const assistantSegmentRef = yield* Ref.make<AcpAssistantSegmentState>({ nextSegmentIndex: 0 }); |
| 261 | const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); | 282 | const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); |
| 262 | const startStateRef = yield* Ref.make<AcpStartState>({ _tag: "NotStarted" }); | 283 | const startStateRef = yield* Ref.make<AcpStartState>({ _tag: "NotStarted" }); |
| 284 | const promptSerializationSemaphore = yield* Semaphore.make(1); | ||
| 285 | const activePromptFiberRef = yield* Ref.make< | ||
| 286 | Option.Option<Fiber.Fiber<EffectAcpSchema.PromptResponse, EffectAcpErrors.AcpError>> | ||
| 287 | >(Option.none()); | ||
| 288 | const sessionLoadGateRef = yield* Ref.make<Option.Option<SessionLoadGate>>(Option.none()); | ||
| 263 | 289 | ||
| 264 | const logRequest = (event: AcpSessionRequestLogEvent) => | 290 | const logRequest = (event: AcpSessionRequestLogEvent) => |
| 265 | options.requestLogger ? options.requestLogger(event) : Effect.void; | 291 | options.requestLogger ? options.requestLogger(event) : Effect.void; |
| ... | @@ -331,15 +357,40 @@ export const make = ( | ... | @@ -331,15 +357,40 @@ export const make = ( |
| 331 | const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); | 357 | const acp = yield* Effect.service(EffectAcpClient.AcpClient).pipe(Effect.provide(acpContext)); |
| 332 | 358 | ||
| 333 | yield* acp.handleSessionUpdate((notification) => | 359 | yield* acp.handleSessionUpdate((notification) => |
| 334 | handleSessionUpdate({ | 360 | Effect.gen(function* () { |
| 335 | queue: eventQueue, | 361 | const gate = yield* Ref.get(sessionLoadGateRef); |
| 336 | modeStateRef, | 362 | if (Option.isSome(gate) && gate.value.active) { |
| 337 | toolCallsRef, | 363 | const lastActivityAtMillis = yield* Clock.currentTimeMillis; |
| 338 | assistantSegmentRef, | 364 | yield* Ref.set( |
| 339 | params: notification, | 365 | sessionLoadGateRef, |
| 366 | Option.some({ | ||
| 367 | ...gate.value, | ||
| 368 | lastActivityAtMillis, | ||
| 369 | }), | ||
| 370 | ); | ||
| 371 | return; | ||
| 372 | } | ||
| 373 | if (sessionUpdateIsReplay(notification)) { | ||
| 374 | return; | ||
| 375 | } | ||
| 376 | const startState = yield* Ref.get(startStateRef); | ||
| 377 | // One runtime projects one root ACP session. Child-session updates need | ||
| 378 | // explicit lineage routing and must never be flattened into this stream. | ||
| 379 | if ( | ||
| 380 | startState._tag !== "Started" || | ||
| 381 | notification.sessionId !== startState.result.sessionId | ||
| 382 | ) { | ||
| 383 | return; | ||
| 384 | } | ||
| 385 | yield* handleSessionUpdate({ | ||
| 386 | queue: eventQueue, | ||
| 387 | modeStateRef, | ||
| 388 | toolCallsRef, | ||
| 389 | assistantSegmentRef, | ||
| 390 | params: notification, | ||
| 391 | }); | ||
| 340 | }), | 392 | }), |
| 341 | ); | 393 | ); |
| 342 | |||
| 343 | const initializeClientCapabilities = { | 394 | const initializeClientCapabilities = { |
| 344 | fs: { | 395 | fs: { |
| 345 | readTextFile: false, | 396 | readTextFile: false, |
| ... | @@ -499,27 +550,74 @@ export const make = ( | ... | @@ -499,27 +550,74 @@ export const make = ( |
| 499 | cwd: options.cwd, | 550 | cwd: options.cwd, |
| 500 | mcpServers: options.mcpServers ?? [], | 551 | mcpServers: options.mcpServers ?? [], |
| 501 | } satisfies EffectAcpSchema.LoadSessionRequest; | 552 | } satisfies EffectAcpSchema.LoadSessionRequest; |
| 502 | const resumed = yield* runLoggedRequest( | 553 | const sessionLoadTimeout = Duration.fromInputUnsafe( |
| 503 | "session/load", | 554 | options.sessionLoadTimeout ?? defaultSessionLoadTimeout, |
| 504 | loadPayload, | 555 | ); |
| 505 | acp.agent.loadSession(loadPayload), | 556 | const sessionLoadReplayIdleGap = Duration.fromInputUnsafe( |
| 506 | ).pipe(Effect.exit); | 557 | options.sessionLoadReplayIdleGap ?? defaultSessionLoadReplayIdleGap, |
| 507 | if (Exit.isSuccess(resumed)) { | 558 | ); |
| 508 | sessionId = options.resumeSessionId; | 559 | |
| 509 | sessionSetupResult = resumed.value; | 560 | yield* Ref.set( |
| 510 | } else { | 561 | sessionLoadGateRef, |
| 511 | const createPayload = { | 562 | Option.some({ |
| 512 | cwd: options.cwd, | 563 | active: true, |
| 513 | mcpServers: options.mcpServers ?? [], | 564 | lastActivityAtMillis: undefined, |
| 514 | } satisfies EffectAcpSchema.NewSessionRequest; | 565 | idleGap: sessionLoadReplayIdleGap, |
| 515 | const created = yield* runLoggedRequest( | 566 | initializeResult, |
| 516 | "session/new", | 567 | }), |
| 517 | createPayload, | 568 | ); |
| 518 | acp.agent.createSession(createPayload), | 569 | |
| 570 | sessionId = options.resumeSessionId; | ||
| 571 | sessionSetupResult = yield* Effect.gen(function* () { | ||
| 572 | yield* logRequest({ | ||
| 573 | method: "session/load", | ||
| 574 | payload: loadPayload, | ||
| 575 | status: "started", | ||
| 576 | }); | ||
| 577 | |||
| 578 | const idleFiber = yield* waitForSessionLoadReplayIdle({ | ||
| 579 | gateRef: sessionLoadGateRef, | ||
| 580 | }).pipe(Effect.forkIn(runtimeScope)); | ||
| 581 | const loaded = yield* Effect.raceFirst( | ||
| 582 | acp.agent.loadSession(loadPayload), | ||
| 583 | Fiber.join(idleFiber), | ||
| 584 | ).pipe( | ||
| 585 | Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)), | ||
| 586 | Effect.timeoutOption(sessionLoadTimeout), | ||
| 587 | Effect.flatMap((result) => | ||
| 588 | Option.match(result, { | ||
| 589 | onNone: () => | ||
| 590 | Effect.fail( | ||
| 591 | new EffectAcpErrors.AcpTransportError({ | ||
| 592 | operation: "call-rpc", | ||
| 593 | method: "session/load", | ||
| 594 | detail: "session/load timed out waiting for RPC response or replay idle gap", | ||
| 595 | cause: undefined, | ||
| 596 | }), | ||
| 597 | ), | ||
| 598 | onSome: Effect.succeed, | ||
| 599 | }), | ||
| 600 | ), | ||
| 601 | Effect.tap((result) => | ||
| 602 | logRequest({ | ||
| 603 | method: "session/load", | ||
| 604 | payload: loadPayload, | ||
| 605 | status: "succeeded", | ||
| 606 | result, | ||
| 607 | }), | ||
| 608 | ), | ||
| 609 | Effect.onError((cause) => | ||
| 610 | logRequest({ | ||
| 611 | method: "session/load", | ||
| 612 | payload: loadPayload, | ||
| 613 | status: "failed", | ||
| 614 | cause, | ||
| 615 | }), | ||
| 616 | ), | ||
| 519 | ); | 617 | ); |
| 520 | sessionId = created.sessionId; | 618 | |
| 521 | sessionSetupResult = created; | 619 | return loaded; |
| 522 | } | 620 | }).pipe(Effect.ensuring(Ref.set(sessionLoadGateRef, Option.none()))); |
| 523 | } else { | 621 | } else { |
| 524 | const createPayload = { | 622 | const createPayload = { |
| 525 | cwd: options.cwd, | 623 | cwd: options.cwd, |
| ... | @@ -596,25 +694,48 @@ export const make = ( | ... | @@ -596,25 +694,48 @@ export const make = ( |
| 596 | handleExtNotification: acp.handleExtNotification, | 694 | handleExtNotification: acp.handleExtNotification, |
| 597 | start: () => start, | 695 | start: () => start, |
| 598 | getEvents: () => Stream.fromQueue(eventQueue), | 696 | getEvents: () => Stream.fromQueue(eventQueue), |
| 697 | drainEvents: Effect.gen(function* () { | ||
| 698 | const acknowledge = yield* Deferred.make<void>(); | ||
| 699 | yield* Queue.offer(eventQueue, { | ||
| 700 | _tag: "EventStreamBarrier", | ||
| 701 | acknowledge, | ||
| 702 | }); | ||
| 703 | yield* Deferred.await(acknowledge); | ||
| 704 | }), | ||
| 599 | getModeState: Ref.get(modeStateRef), | 705 | getModeState: Ref.get(modeStateRef), |
| 600 | getConfigOptions: Ref.get(configOptionsRef), | 706 | getConfigOptions: Ref.get(configOptionsRef), |
| 601 | prompt: (payload) => | 707 | prompt: (payload) => |
| 602 | getStartedState.pipe( | 708 | promptSerializationSemaphore.withPermit( |
| 603 | Effect.flatMap((started) => { | 709 | Effect.gen(function* () { |
| 710 | const started = yield* getStartedState; | ||
| 711 | yield* closeActiveAssistantSegment({ | ||
| 712 | queue: eventQueue, | ||
| 713 | assistantSegmentRef, | ||
| 714 | }); | ||
| 604 | const requestPayload = { | 715 | const requestPayload = { |
| 605 | sessionId: started.sessionId, | 716 | sessionId: started.sessionId, |
| 606 | ...payload, | 717 | ...payload, |
| 607 | } satisfies EffectAcpSchema.PromptRequest; | 718 | } satisfies EffectAcpSchema.PromptRequest; |
| 608 | return closeActiveAssistantSegment({ | 719 | const cancelledResponse = { |
| 609 | queue: eventQueue, | 720 | stopReason: "cancelled", |
| 610 | assistantSegmentRef, | 721 | } satisfies EffectAcpSchema.PromptResponse; |
| 611 | }).pipe( | 722 | const promptRpcFiber = yield* runLoggedRequest( |
| 612 | Effect.andThen( | 723 | "session/prompt", |
| 613 | runLoggedRequest( | 724 | requestPayload, |
| 614 | "session/prompt", | 725 | acp.agent.prompt(requestPayload), |
| 615 | requestPayload, | 726 | ).pipe(Effect.forkIn(runtimeScope)); |
| 616 | acp.agent.prompt(requestPayload), | 727 | yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber)); |
| 617 | ), | 728 | return yield* Fiber.join(promptRpcFiber).pipe( |
| 729 | Effect.catchCause((cause) => | ||
| 730 | Cause.hasInterruptsOnly(cause) | ||
| 731 | ? Effect.succeed(cancelledResponse) | ||
| 732 | : Effect.failCause(cause), | ||
| 733 | ), | ||
| 734 | Effect.ensuring( | ||
| 735 | Effect.gen(function* () { | ||
| 736 | yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); | ||
| 737 | yield* Ref.set(activePromptFiberRef, Option.none()); | ||
| 738 | }), | ||
| 618 | ), | 739 | ), |
| 619 | Effect.tap(() => | 740 | Effect.tap(() => |
| 620 | closeActiveAssistantSegment({ | 741 | closeActiveAssistantSegment({ |
| ... | @@ -626,7 +747,17 @@ export const make = ( | ... | @@ -626,7 +747,17 @@ export const make = ( |
| 626 | }), | 747 | }), |
| 627 | ), | 748 | ), |
| 628 | cancel: getStartedState.pipe( | 749 | cancel: getStartedState.pipe( |
| 629 | Effect.flatMap((started) => acp.agent.cancel({ sessionId: started.sessionId })), | 750 | Effect.flatMap((started) => |
| 751 | Effect.gen(function* () { | ||
| 752 | const activePromptFiber = yield* Ref.get(activePromptFiberRef); | ||
| 753 | if (Option.isSome(activePromptFiber)) { | ||
| 754 | yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore); | ||
| 755 | } | ||
| 756 | yield* acp.agent | ||
| 757 | .cancel({ sessionId: started.sessionId }) | ||
| 758 | .pipe(Effect.ignore, Effect.forkIn(runtimeScope)); | ||
| 759 | }), | ||
| 760 | ), | ||
| 630 | ), | 761 | ), |
| 631 | setMode: (modeId) => | 762 | setMode: (modeId) => |
| 632 | Ref.get(modeStateRef).pipe( | 763 | Ref.get(modeStateRef).pipe( |
| ... | @@ -705,7 +836,7 @@ const handleSessionUpdate = ({ | ... | @@ -705,7 +836,7 @@ const handleSessionUpdate = ({ |
| 705 | assistantSegmentRef, | 836 | assistantSegmentRef, |
| 706 | params, | 837 | params, |
| 707 | }: { | 838 | }: { |
| 708 | readonly queue: Queue.Queue<AcpParsedSessionEvent>; | 839 | readonly queue: Queue.Queue<AcpSessionRuntimeEvent>; |
| 709 | readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>; | 840 | readonly modeStateRef: Ref.Ref<AcpSessionModeState | undefined>; |
| 710 | readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallState>>; | 841 | readonly toolCallsRef: Ref.Ref<Map<string, AcpToolCallState>>; |
| 711 | readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>; | 842 | readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>; |
| ... | @@ -801,7 +932,7 @@ const ensureActiveAssistantSegment = ({ | ... | @@ -801,7 +932,7 @@ const ensureActiveAssistantSegment = ({ |
| 801 | assistantSegmentRef, | 932 | assistantSegmentRef, |
| 802 | sessionId, | 933 | sessionId, |
| 803 | }: { | 934 | }: { |
| 804 | readonly queue: Queue.Queue<AcpParsedSessionEvent>; | 935 | readonly queue: Queue.Queue<AcpSessionRuntimeEvent>; |
| 805 | readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>; | 936 | readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>; |
| 806 | readonly sessionId: string; | 937 | readonly sessionId: string; |
| 807 | }) => | 938 | }) => |
| ... | @@ -838,7 +969,7 @@ const closeActiveAssistantSegment = ({ | ... | @@ -838,7 +969,7 @@ const closeActiveAssistantSegment = ({ |
| 838 | queue, | 969 | queue, |
| 839 | assistantSegmentRef, | 970 | assistantSegmentRef, |
| 840 | }: { | 971 | }: { |
| 841 | readonly queue: Queue.Queue<AcpParsedSessionEvent>; | 972 | readonly queue: Queue.Queue<AcpSessionRuntimeEvent>; |
| 842 | readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>; | 973 | readonly assistantSegmentRef: Ref.Ref<AcpAssistantSegmentState>; |
| 843 | }) => | 974 | }) => |
| 844 | Ref.modify(assistantSegmentRef, (current) => { | 975 | Ref.modify(assistantSegmentRef, (current) => { |
apps/server/src/provider/acp/GrokAcpSupport.ts+3-1| ... | @@ -8,6 +8,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; | ... | @@ -8,6 +8,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; |
| 8 | import { normalizeModelSlug } from "@t3tools/shared/model"; | 8 | import { normalizeModelSlug } from "@t3tools/shared/model"; |
| 9 | 9 | ||
| 10 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | 10 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; |
| 11 | import { makeXAiPromptCompletionRuntime } from "./XAiAcpExtension.ts"; | ||
| 11 | 12 | ||
| 12 | const GROK_API_KEY_ENV = "XAI_API_KEY"; | 13 | const GROK_API_KEY_ENV = "XAI_API_KEY"; |
| 13 | const GROK_OAUTH2_REFERRER_ENV = "GROK_OAUTH2_REFERRER"; | 14 | const GROK_OAUTH2_REFERRER_ENV = "GROK_OAUTH2_REFERRER"; |
| ... | @@ -68,9 +69,10 @@ export const makeGrokAcpRuntime = ( | ... | @@ -68,9 +69,10 @@ export const makeGrokAcpRuntime = ( |
| 68 | ), | 69 | ), |
| 69 | ), | 70 | ), |
| 70 | ); | 71 | ); |
| 71 | return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( | 72 | const runtime = yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( |
| 72 | Effect.provide(acpContext), | 73 | Effect.provide(acpContext), |
| 73 | ); | 74 | ); |
| 75 | return yield* makeXAiPromptCompletionRuntime(runtime); | ||
| 74 | }); | 76 | }); |
| 75 | 77 | ||
| 76 | export function resolveGrokAcpBaseModelId(model: string | null | undefined): string { | 78 | export function resolveGrokAcpBaseModelId(model: string | null | undefined): string { |
apps/server/src/provider/acp/XAiAcpExtension.test.ts+83-1| ... | @@ -1,12 +1,39 @@ | ... | @@ -1,12 +1,39 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodePath from "node:path"; | ||
| 3 | import * as NodeURL from "node:url"; | ||
| 4 | |||
| 5 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 6 | import { it } from "@effect/vitest"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Schema from "effect/Schema"; | 8 | import * as Schema from "effect/Schema"; |
| 9 | import { describe, expect } from "vite-plus/test"; | ||
| 3 | 10 | ||
| 4 | import { | 11 | import { |
| 5 | extractXAiAskUserQuestions, | 12 | extractXAiAskUserQuestions, |
| 6 | makeXAiAskUserQuestionCancelledResponse, | 13 | makeXAiAskUserQuestionCancelledResponse, |
| 7 | makeXAiAskUserQuestionResponse, | 14 | makeXAiAskUserQuestionResponse, |
| 15 | makeXAiPromptCompletionRuntime, | ||
| 8 | XAiAskUserQuestionRequest, | 16 | XAiAskUserQuestionRequest, |
| 9 | } from "./XAiAcpExtension.ts"; | 17 | } from "./XAiAcpExtension.ts"; |
| 18 | import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | ||
| 19 | |||
| 20 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | ||
| 21 | const mockAgentPath = NodePath.join(__dirname, "../../../scripts/acp-mock-agent.ts"); | ||
| 22 | |||
| 23 | const makePromptCompletionRuntime = (env: NodeJS.ProcessEnv) => | ||
| 24 | Effect.gen(function* () { | ||
| 25 | const runtime = yield* AcpSessionRuntime.make({ | ||
| 26 | spawn: { | ||
| 27 | command: process.execPath, | ||
| 28 | args: [mockAgentPath], | ||
| 29 | env, | ||
| 30 | }, | ||
| 31 | cwd: process.cwd(), | ||
| 32 | clientInfo: { name: "t3-test", version: "0.0.0" }, | ||
| 33 | authMethodId: "test", | ||
| 34 | }); | ||
| 35 | return yield* makeXAiPromptCompletionRuntime(runtime); | ||
| 36 | }); | ||
| 10 | 37 | ||
| 11 | const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); | 38 | const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest); |
| 12 | 39 | ||
| ... | @@ -247,4 +274,59 @@ describe("XAiAcpExtension", () => { | ... | @@ -247,4 +274,59 @@ describe("XAiAcpExtension", () => { |
| 247 | }, | 274 | }, |
| 248 | }); | 275 | }); |
| 249 | }); | 276 | }); |
| 277 | |||
| 278 | it.effect("resolves a hung standard prompt from xAI prompt completion", () => | ||
| 279 | Effect.gen(function* () { | ||
| 280 | const runtime = yield* makePromptCompletionRuntime({ | ||
| 281 | T3_ACP_EMIT_XAI_PROMPT_COMPLETE_THEN_HANG: "1", | ||
| 282 | }); | ||
| 283 | yield* runtime.start(); | ||
| 284 | |||
| 285 | const promptResult = yield* runtime.prompt({ | ||
| 286 | prompt: [{ type: "text", text: "hi" }], | ||
| 287 | }); | ||
| 288 | const promptId = promptResult._meta?.promptId; | ||
| 289 | |||
| 290 | expect(typeof promptId).toBe("string"); | ||
| 291 | expect(promptResult).toMatchObject({ | ||
| 292 | stopReason: "end_turn", | ||
| 293 | _meta: { | ||
| 294 | sessionId: "mock-session-1", | ||
| 295 | promptId, | ||
| 296 | requestId: promptId, | ||
| 297 | }, | ||
| 298 | }); | ||
| 299 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 300 | ); | ||
| 301 | |||
| 302 | it.effect("ignores stale xAI completion from an already settled prompt", () => | ||
| 303 | Effect.gen(function* () { | ||
| 304 | const runtime = yield* makePromptCompletionRuntime({ | ||
| 305 | T3_ACP_EMIT_STALE_XAI_PROMPT_COMPLETE_BEFORE_SECOND_HANG: "1", | ||
| 306 | }); | ||
| 307 | yield* runtime.start(); | ||
| 308 | |||
| 309 | const firstPromptResult = yield* runtime.prompt({ | ||
| 310 | prompt: [{ type: "text", text: "first" }], | ||
| 311 | }); | ||
| 312 | expect(firstPromptResult).toMatchObject({ | ||
| 313 | stopReason: "end_turn", | ||
| 314 | _meta: { promptId: "mock-stale-xai-prompt-1" }, | ||
| 315 | }); | ||
| 316 | |||
| 317 | const secondPromptResult = yield* runtime.prompt({ | ||
| 318 | prompt: [{ type: "text", text: "second" }], | ||
| 319 | }); | ||
| 320 | const secondPromptId = secondPromptResult._meta?.promptId; | ||
| 321 | expect(typeof secondPromptId).toBe("string"); | ||
| 322 | expect(secondPromptId).not.toBe("mock-stale-xai-prompt-1"); | ||
| 323 | expect(secondPromptResult).toMatchObject({ | ||
| 324 | stopReason: "end_turn", | ||
| 325 | _meta: { | ||
| 326 | promptId: secondPromptId, | ||
| 327 | requestId: secondPromptId, | ||
| 328 | }, | ||
| 329 | }); | ||
| 330 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | ||
| 331 | ); | ||
| 250 | }); | 332 | }); |
apps/server/src/provider/acp/XAiAcpExtension.ts+259| ... | @@ -1,5 +1,29 @@ | ... | @@ -1,5 +1,29 @@ |
| 1 | import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; | 1 | import type { ProviderUserInputAnswers, UserInputQuestion } from "@t3tools/contracts"; |
| 2 | import * as Deferred from "effect/Deferred"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Ref from "effect/Ref"; | ||
| 2 | import * as Schema from "effect/Schema"; | 5 | import * as Schema from "effect/Schema"; |
| 6 | import type * as EffectAcpSchema from "effect-acp/schema"; | ||
| 7 | |||
| 8 | import type * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; | ||
| 9 | |||
| 10 | const XAiPromptCompleteNotification = Schema.Struct({ | ||
| 11 | sessionId: Schema.String, | ||
| 12 | promptId: Schema.optional(Schema.String), | ||
| 13 | stopReason: Schema.optional(Schema.String), | ||
| 14 | agentResult: Schema.optional(Schema.NullOr(Schema.Unknown)), | ||
| 15 | }); | ||
| 16 | |||
| 17 | type XAiPromptCompleteNotification = typeof XAiPromptCompleteNotification.Type; | ||
| 18 | |||
| 19 | interface PendingXAiPromptCompletion { | ||
| 20 | readonly sessionId: string; | ||
| 21 | readonly promptId: string; | ||
| 22 | readonly deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse>; | ||
| 23 | } | ||
| 24 | |||
| 25 | const completedXAiPromptIdLimit = 128; | ||
| 26 | const xAiStopReasonMissingMetaKey = "xAiStopReasonMissing"; | ||
| 3 | 27 | ||
| 4 | const XAiAskUserQuestionOption = Schema.Struct({ | 28 | const XAiAskUserQuestionOption = Schema.Struct({ |
| 5 | label: Schema.String, | 29 | label: Schema.String, |
| ... | @@ -171,3 +195,238 @@ export function makeXAiAskUserQuestionResponse( | ... | @@ -171,3 +195,238 @@ export function makeXAiAskUserQuestionResponse( |
| 171 | export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCancelledResponse { | 195 | export function makeXAiAskUserQuestionCancelledResponse(): XAiAskUserQuestionCancelledResponse { |
| 172 | return { outcome: "cancelled" }; | 196 | return { outcome: "cancelled" }; |
| 173 | } | 197 | } |
| 198 | |||
| 199 | /** | ||
| 200 | * Adds Grok's private prompt-completion fallback around a standards-only ACP runtime. | ||
| 201 | * The underlying runtime remains unaware of xAI methods and metadata. | ||
| 202 | */ | ||
| 203 | export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletionRuntime")( | ||
| 204 | function* (runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]) { | ||
| 205 | const activeSessionIdRef = yield* Ref.make<string | undefined>(undefined); | ||
| 206 | const pendingRef = yield* Ref.make<ReadonlyArray<PendingXAiPromptCompletion>>([]); | ||
| 207 | const completedPromptIdsRef = yield* Ref.make<ReadonlyArray<string>>([]); | ||
| 208 | let nextPromptFallbackId = 0; | ||
| 209 | const allocatePromptFallbackId = Effect.sync(() => { | ||
| 210 | nextPromptFallbackId += 1; | ||
| 211 | return `t3-xai-prompt-${nextPromptFallbackId}`; | ||
| 212 | }); | ||
| 213 | |||
| 214 | yield* runtime.handleExtNotification( | ||
| 215 | "_x.ai/session/prompt_complete", | ||
| 216 | XAiPromptCompleteNotification, | ||
| 217 | (notification) => | ||
| 218 | resolveXAiPromptCompletionFallback({ | ||
| 219 | pendingRef, | ||
| 220 | completedPromptIdsRef, | ||
| 221 | notification, | ||
| 222 | }), | ||
| 223 | ); | ||
| 224 | |||
| 225 | return { | ||
| 226 | ...runtime, | ||
| 227 | start: () => | ||
| 228 | runtime | ||
| 229 | .start() | ||
| 230 | .pipe(Effect.tap((started) => Ref.set(activeSessionIdRef, started.sessionId))), | ||
| 231 | prompt: (payload) => | ||
| 232 | Effect.gen(function* () { | ||
| 233 | const sessionId = yield* Ref.get(activeSessionIdRef); | ||
| 234 | if (sessionId === undefined) { | ||
| 235 | return yield* runtime.prompt(payload); | ||
| 236 | } | ||
| 237 | |||
| 238 | const promptId = yield* allocatePromptFallbackId; | ||
| 239 | const fallback = yield* registerXAiPromptCompletionFallback( | ||
| 240 | pendingRef, | ||
| 241 | sessionId, | ||
| 242 | promptId, | ||
| 243 | ); | ||
| 244 | const requestPayload = { | ||
| 245 | ...payload, | ||
| 246 | _meta: { | ||
| 247 | ...payload._meta, | ||
| 248 | promptId: fallback.promptId, | ||
| 249 | requestId: fallback.promptId, | ||
| 250 | }, | ||
| 251 | } satisfies Omit<EffectAcpSchema.PromptRequest, "sessionId">; | ||
| 252 | |||
| 253 | return yield* Effect.raceFirst( | ||
| 254 | runtime.prompt(requestPayload), | ||
| 255 | Deferred.await(fallback.deferred), | ||
| 256 | ).pipe( | ||
| 257 | Effect.tap((response) => | ||
| 258 | rememberCompletedXAiPromptId(completedPromptIdsRef, response, fallback.promptId), | ||
| 259 | ), | ||
| 260 | Effect.ensuring(unregisterXAiPromptCompletionFallback(pendingRef, fallback.deferred)), | ||
| 261 | ); | ||
| 262 | }), | ||
| 263 | cancel: Ref.get(activeSessionIdRef).pipe( | ||
| 264 | Effect.flatMap((sessionId) => | ||
| 265 | sessionId === undefined | ||
| 266 | ? runtime.cancel | ||
| 267 | : abortPendingPromptCompletions(pendingRef, sessionId).pipe( | ||
| 268 | Effect.andThen(runtime.cancel), | ||
| 269 | ), | ||
| 270 | ), | ||
| 271 | ), | ||
| 272 | } satisfies AcpSessionRuntime.AcpSessionRuntime["Service"]; | ||
| 273 | }, | ||
| 274 | ); | ||
| 275 | |||
| 276 | const registerXAiPromptCompletionFallback = ( | ||
| 277 | pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>, | ||
| 278 | sessionId: string, | ||
| 279 | promptId: string, | ||
| 280 | ) => | ||
| 281 | Deferred.make<EffectAcpSchema.PromptResponse>().pipe( | ||
| 282 | Effect.tap((deferred) => | ||
| 283 | Ref.update(pendingRef, (pending) => [...pending, { sessionId, promptId, deferred }]), | ||
| 284 | ), | ||
| 285 | Effect.map((deferred) => ({ deferred, promptId })), | ||
| 286 | ); | ||
| 287 | |||
| 288 | const unregisterXAiPromptCompletionFallback = ( | ||
| 289 | pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>, | ||
| 290 | deferred: Deferred.Deferred<EffectAcpSchema.PromptResponse>, | ||
| 291 | ) => Ref.update(pendingRef, (pending) => pending.filter((entry) => entry.deferred !== deferred)); | ||
| 292 | |||
| 293 | const abortPendingPromptCompletions = ( | ||
| 294 | pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>, | ||
| 295 | sessionId: string, | ||
| 296 | ) => | ||
| 297 | Ref.modify(pendingRef, (pending) => { | ||
| 298 | const [toAbort, remaining] = pending.reduce< | ||
| 299 | [ReadonlyArray<PendingXAiPromptCompletion>, ReadonlyArray<PendingXAiPromptCompletion>] | ||
| 300 | >( | ||
| 301 | ([aborting, kept], entry) => | ||
| 302 | entry.sessionId === sessionId ? [[...aborting, entry], kept] : [aborting, [...kept, entry]], | ||
| 303 | [[], []], | ||
| 304 | ); | ||
| 305 | if (toAbort.length === 0) { | ||
| 306 | return [Effect.void, pending] as const; | ||
| 307 | } | ||
| 308 | return [ | ||
| 309 | Effect.forEach( | ||
| 310 | toAbort, | ||
| 311 | (entry) => | ||
| 312 | Deferred.succeed( | ||
| 313 | entry.deferred, | ||
| 314 | promptResponseFromXAi({ | ||
| 315 | sessionId: entry.sessionId, | ||
| 316 | promptId: entry.promptId, | ||
| 317 | stopReason: "cancelled", | ||
| 318 | agentResult: null, | ||
| 319 | }), | ||
| 320 | ), | ||
| 321 | { concurrency: "unbounded" }, | ||
| 322 | ).pipe(Effect.asVoid), | ||
| 323 | remaining, | ||
| 324 | ] as const; | ||
| 325 | }).pipe(Effect.flatten); | ||
| 326 | |||
| 327 | const resolveXAiPromptCompletionFallback = ({ | ||
| 328 | pendingRef, | ||
| 329 | completedPromptIdsRef, | ||
| 330 | notification, | ||
| 331 | }: { | ||
| 332 | readonly pendingRef: Ref.Ref<ReadonlyArray<PendingXAiPromptCompletion>>; | ||
| 333 | readonly completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>; | ||
| 334 | readonly notification: XAiPromptCompleteNotification; | ||
| 335 | }) => | ||
| 336 | Ref.get(completedPromptIdsRef).pipe( | ||
| 337 | Effect.flatMap((completedPromptIds) => { | ||
| 338 | if ( | ||
| 339 | notification.promptId !== undefined && | ||
| 340 | completedPromptIds.includes(notification.promptId) | ||
| 341 | ) { | ||
| 342 | return Effect.void; | ||
| 343 | } | ||
| 344 | return Ref.modify(pendingRef, (pending) => { | ||
| 345 | const index = | ||
| 346 | notification.promptId !== undefined | ||
| 347 | ? pending.findIndex( | ||
| 348 | (entry) => | ||
| 349 | entry.sessionId === notification.sessionId && | ||
| 350 | entry.promptId === notification.promptId, | ||
| 351 | ) | ||
| 352 | : pending.findIndex((entry) => entry.sessionId === notification.sessionId); | ||
| 353 | if (index < 0) { | ||
| 354 | return [Effect.void, pending] as const; | ||
| 355 | } | ||
| 356 | const entry = pending[index]; | ||
| 357 | if (!entry) { | ||
| 358 | return [Effect.void, pending] as const; | ||
| 359 | } | ||
| 360 | return [ | ||
| 361 | Deferred.succeed(entry.deferred, promptResponseFromXAi(notification)).pipe(Effect.asVoid), | ||
| 362 | [...pending.slice(0, index), ...pending.slice(index + 1)], | ||
| 363 | ] as const; | ||
| 364 | }).pipe(Effect.flatten); | ||
| 365 | }), | ||
| 366 | ); | ||
| 367 | |||
| 368 | const rememberCompletedXAiPromptId = ( | ||
| 369 | completedPromptIdsRef: Ref.Ref<ReadonlyArray<string>>, | ||
| 370 | response: EffectAcpSchema.PromptResponse, | ||
| 371 | fallbackPromptId: string, | ||
| 372 | ) => { | ||
| 373 | const promptId = promptIdFromResponse(response) ?? fallbackPromptId; | ||
| 374 | return Ref.update(completedPromptIdsRef, (completedPromptIds) => { | ||
| 375 | if (completedPromptIds.includes(promptId)) { | ||
| 376 | return completedPromptIds; | ||
| 377 | } | ||
| 378 | return [...completedPromptIds, promptId].slice(-completedXAiPromptIdLimit); | ||
| 379 | }); | ||
| 380 | }; | ||
| 381 | |||
| 382 | function promptIdFromResponse(response: EffectAcpSchema.PromptResponse): string | undefined { | ||
| 383 | const meta = response._meta; | ||
| 384 | if (meta === null || typeof meta !== "object") { | ||
| 385 | return undefined; | ||
| 386 | } | ||
| 387 | const promptId = meta.promptId ?? meta.requestId; | ||
| 388 | return typeof promptId === "string" && promptId.length > 0 ? promptId : undefined; | ||
| 389 | } | ||
| 390 | |||
| 391 | export function promptResponseHasMissingXAiStopReason( | ||
| 392 | response: EffectAcpSchema.PromptResponse, | ||
| 393 | ): boolean { | ||
| 394 | const meta = response._meta; | ||
| 395 | return meta !== null && typeof meta === "object" && meta[xAiStopReasonMissingMetaKey] === true; | ||
| 396 | } | ||
| 397 | |||
| 398 | function promptResponseFromXAi( | ||
| 399 | notification: XAiPromptCompleteNotification, | ||
| 400 | ): EffectAcpSchema.PromptResponse { | ||
| 401 | const stopReason = normalizeXAiStopReason(notification.stopReason); | ||
| 402 | const meta: Record<string, unknown> = { | ||
| 403 | sessionId: notification.sessionId, | ||
| 404 | }; | ||
| 405 | if (notification.stopReason === undefined) { | ||
| 406 | meta[xAiStopReasonMissingMetaKey] = true; | ||
| 407 | } | ||
| 408 | if (notification.promptId !== undefined) { | ||
| 409 | meta.promptId = notification.promptId; | ||
| 410 | meta.requestId = notification.promptId; | ||
| 411 | } | ||
| 412 | if (notification.agentResult !== undefined) { | ||
| 413 | meta.agentResult = notification.agentResult; | ||
| 414 | } | ||
| 415 | return { | ||
| 416 | stopReason, | ||
| 417 | _meta: meta, | ||
| 418 | }; | ||
| 419 | } | ||
| 420 | |||
| 421 | function normalizeXAiStopReason(value: string | undefined): EffectAcpSchema.StopReason { | ||
| 422 | switch (value) { | ||
| 423 | case "cancelled": | ||
| 424 | case "end_turn": | ||
| 425 | case "max_tokens": | ||
| 426 | case "max_turn_requests": | ||
| 427 | case "refusal": | ||
| 428 | return value; | ||
| 429 | default: | ||
| 430 | return "end_turn"; | ||
| 431 | } | ||
| 432 | } |
apps/web/src/components/ChatView.logic.test.ts+25| ... | @@ -6,6 +6,7 @@ import { | ... | @@ -6,6 +6,7 @@ import { |
| 6 | MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, | 6 | MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, |
| 7 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | 7 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| 8 | buildExpiredTerminalContextToastCopy, | 8 | buildExpiredTerminalContextToastCopy, |
| 9 | buildThreadTurnInterruptInput, | ||
| 9 | createLocalDispatchSnapshot, | 10 | createLocalDispatchSnapshot, |
| 10 | deriveComposerSendState, | 11 | deriveComposerSendState, |
| 11 | getStartedThreadModelChangeBlockReason, | 12 | getStartedThreadModelChangeBlockReason, |
| ... | @@ -69,6 +70,30 @@ const readySession = { | ... | @@ -69,6 +70,30 @@ const readySession = { |
| 69 | updatedAt: "2026-03-29T00:00:10.000Z", | 70 | updatedAt: "2026-03-29T00:00:10.000Z", |
| 70 | }; | 71 | }; |
| 71 | 72 | ||
| 73 | describe("buildThreadTurnInterruptInput", () => { | ||
| 74 | it("targets the session's active running turn", () => { | ||
| 75 | const activeTurnId = TurnId.make("turn-running"); | ||
| 76 | |||
| 77 | expect( | ||
| 78 | buildThreadTurnInterruptInput( | ||
| 79 | makeThread({ | ||
| 80 | session: { | ||
| 81 | ...readySession, | ||
| 82 | status: "running", | ||
| 83 | activeTurnId, | ||
| 84 | }, | ||
| 85 | }), | ||
| 86 | ), | ||
| 87 | ).toEqual({ threadId, turnId: activeTurnId }); | ||
| 88 | }); | ||
| 89 | |||
| 90 | it("omits a turn id when the session is not running", () => { | ||
| 91 | expect(buildThreadTurnInterruptInput(makeThread({ session: readySession }))).toEqual({ | ||
| 92 | threadId, | ||
| 93 | }); | ||
| 94 | }); | ||
| 95 | }); | ||
| 96 | |||
| 72 | describe("deriveComposerSendState", () => { | 97 | describe("deriveComposerSendState", () => { |
| 73 | it("treats expired terminal pills as non-sendable content", () => { | 98 | it("treats expired terminal pills as non-sendable content", () => { |
| 74 | const state = deriveComposerSendState({ | 99 | const state = deriveComposerSendState({ |
apps/web/src/components/ChatView.logic.ts+11| ... | @@ -74,6 +74,17 @@ export function shouldWriteThreadErrorToCurrentServerThread(input: { | ... | @@ -74,6 +74,17 @@ export function shouldWriteThreadErrorToCurrentServerThread(input: { |
| 74 | ); | 74 | ); |
| 75 | } | 75 | } |
| 76 | 76 | ||
| 77 | export function buildThreadTurnInterruptInput(thread: Pick<Thread, "id" | "session">): { | ||
| 78 | threadId: ThreadId; | ||
| 79 | turnId?: TurnId; | ||
| 80 | } { | ||
| 81 | const runningTurnId = thread.session?.status === "running" ? thread.session.activeTurnId : null; | ||
| 82 | return { | ||
| 83 | threadId: thread.id, | ||
| 84 | ...(runningTurnId !== null ? { turnId: runningTurnId } : {}), | ||
| 85 | }; | ||
| 86 | } | ||
| 87 | |||
| 77 | export function reconcileMountedTerminalThreadIds(input: { | 88 | export function reconcileMountedTerminalThreadIds(input: { |
| 78 | currentThreadIds: ReadonlyArray<string>; | 89 | currentThreadIds: ReadonlyArray<string>; |
| 79 | openThreadIds: ReadonlyArray<string>; | 90 | openThreadIds: ReadonlyArray<string>; |
apps/web/src/components/ChatView.tsx+7-3| ... | @@ -214,6 +214,7 @@ import { | ... | @@ -214,6 +214,7 @@ import { |
| 214 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | 214 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| 215 | buildExpiredTerminalContextToastCopy, | 215 | buildExpiredTerminalContextToastCopy, |
| 216 | buildLocalDraftThread, | 216 | buildLocalDraftThread, |
| 217 | buildThreadTurnInterruptInput, | ||
| 217 | collectUserMessageBlobPreviewUrls, | 218 | collectUserMessageBlobPreviewUrls, |
| 218 | createLocalDispatchSnapshot, | 219 | createLocalDispatchSnapshot, |
| 219 | deriveComposerSendState, | 220 | deriveComposerSendState, |
| ... | @@ -3941,9 +3942,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -3941,9 +3942,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 3941 | if (!activeThread) return; | 3942 | if (!activeThread) return; |
| 3942 | const result = await interruptThreadTurn({ | 3943 | const result = await interruptThreadTurn({ |
| 3943 | environmentId, | 3944 | environmentId, |
| 3944 | input: { | 3945 | input: buildThreadTurnInterruptInput(activeThread), |
| 3945 | threadId: activeThread.id, | ||
| 3946 | }, | ||
| 3947 | }); | 3946 | }); |
| 3948 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | 3947 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { |
| 3949 | const error = squashAtomCommandFailure(result); | 3948 | const error = squashAtomCommandFailure(result); |
| ... | @@ -4771,6 +4770,11 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4771,6 +4770,11 @@ function ChatViewContent(props: ChatViewProps) { |
| 4771 | listRef={legendListRef} | 4770 | listRef={legendListRef} |
| 4772 | timelineEntries={timelineEntries} | 4771 | timelineEntries={timelineEntries} |
| 4773 | latestTurn={activeLatestTurn} | 4772 | latestTurn={activeLatestTurn} |
| 4773 | runningTurnId={ | ||
| 4774 | activeThread.session?.status === "running" | ||
| 4775 | ? activeThread.session.activeTurnId | ||
| 4776 | : null | ||
| 4777 | } | ||
| 4774 | turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} | 4778 | turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} |
| 4775 | activeThreadEnvironmentId={activeThread.environmentId} | 4779 | activeThreadEnvironmentId={activeThread.environmentId} |
| 4776 | routeThreadKey={routeThreadKey} | 4780 | routeThreadKey={routeThreadKey} |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+61| ... | @@ -794,6 +794,67 @@ describe("deriveMessagesTimelineRows", () => { | ... | @@ -794,6 +794,67 @@ describe("deriveMessagesTimelineRows", () => { |
| 794 | ]); | 794 | ]); |
| 795 | }); | 795 | }); |
| 796 | 796 | ||
| 797 | it("does not fold the session's running turn when latestTurn regresses", () => { | ||
| 798 | const rows = deriveMessagesTimelineRows({ | ||
| 799 | timelineEntries: [ | ||
| 800 | { | ||
| 801 | id: "previous-work-entry", | ||
| 802 | kind: "work", | ||
| 803 | createdAt: "2026-01-01T00:00:05Z", | ||
| 804 | entry: { | ||
| 805 | id: "previous-work", | ||
| 806 | createdAt: "2026-01-01T00:00:05Z", | ||
| 807 | turnId: "turn-1" as never, | ||
| 808 | label: "Read files", | ||
| 809 | tone: "tool" as const, | ||
| 810 | }, | ||
| 811 | }, | ||
| 812 | { | ||
| 813 | id: "user-followup-entry", | ||
| 814 | kind: "message", | ||
| 815 | createdAt: "2026-01-01T00:01:00Z", | ||
| 816 | message: { | ||
| 817 | id: "user-followup" as never, | ||
| 818 | role: "user", | ||
| 819 | text: "continue", | ||
| 820 | turnId: null, | ||
| 821 | createdAt: "2026-01-01T00:01:00Z", | ||
| 822 | updatedAt: "2026-01-01T00:01:00Z", | ||
| 823 | streaming: false, | ||
| 824 | }, | ||
| 825 | }, | ||
| 826 | { | ||
| 827 | id: "running-work-entry", | ||
| 828 | kind: "work", | ||
| 829 | createdAt: "2026-01-01T00:01:05Z", | ||
| 830 | entry: { | ||
| 831 | id: "running-work", | ||
| 832 | createdAt: "2026-01-01T00:01:05Z", | ||
| 833 | turnId: "turn-2" as never, | ||
| 834 | label: "Searched files", | ||
| 835 | tone: "tool" as const, | ||
| 836 | }, | ||
| 837 | }, | ||
| 838 | ], | ||
| 839 | latestTurn: { | ||
| 840 | turnId: "turn-1" as never, | ||
| 841 | state: "completed", | ||
| 842 | startedAt: "2026-01-01T00:00:00Z", | ||
| 843 | completedAt: "2026-01-01T00:00:25Z", | ||
| 844 | }, | ||
| 845 | runningTurnId: "turn-2" as never, | ||
| 846 | isWorking: true, | ||
| 847 | activeTurnStartedAt: "2026-01-01T00:01:00Z", | ||
| 848 | turnDiffSummaryByAssistantMessageId: new Map(), | ||
| 849 | revertTurnCountByUserMessageId: new Map(), | ||
| 850 | }); | ||
| 851 | |||
| 852 | expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ | ||
| 853 | "turn-1", | ||
| 854 | ]); | ||
| 855 | expect(rows.map((row) => row.id)).toContain("running-work-entry"); | ||
| 856 | }); | ||
| 857 | |||
| 797 | it("only shows assistant metadata on the terminal assistant message", () => { | 858 | it("only shows assistant metadata on the terminal assistant message", () => { |
| 798 | const rows = deriveMessagesTimelineRows({ | 859 | const rows = deriveMessagesTimelineRows({ |
| 799 | timelineEntries: [ | 860 | timelineEntries: [ |
apps/web/src/components/chat/MessagesTimeline.logic.ts+18-7| ... | @@ -149,13 +149,20 @@ interface TurnFold { | ... | @@ -149,13 +149,20 @@ interface TurnFold { |
| 149 | } | 149 | } |
| 150 | 150 | ||
| 151 | /** | 151 | /** |
| 152 | * The latest turn counts as unsettled while it is still running (or has not | 152 | * The session's running turn is authoritative when latestTurn briefly lags or |
| 153 | * recorded a completion). This is deliberately keyed on the turn's own | 153 | * regresses behind it. Otherwise, the latest turn counts as unsettled while it |
| 154 | * lifecycle rather than transient working state: right after the user sends | 154 | * is still running (or has not recorded a completion). This is deliberately |
| 155 | * a message, the previous turn is still the "active" one until the server | 155 | * keyed on turn lifecycle rather than transient working state: right after the |
| 156 | * creates the new turn, and folding must not flicker through that window. | 156 | * user sends a message, the previous turn is still the "active" one until the |
| 157 | * server creates the new turn, and folding must not flicker through that window. | ||
| 157 | */ | 158 | */ |
| 158 | function deriveUnsettledTurnId(latestTurn: TimelineLatestTurn | null): TurnId | null { | 159 | function deriveUnsettledTurnId( |
| 160 | latestTurn: TimelineLatestTurn | null, | ||
| 161 | runningTurnId: TurnId | null, | ||
| 162 | ): TurnId | null { | ||
| 163 | if (runningTurnId !== null) { | ||
| 164 | return runningTurnId; | ||
| 165 | } | ||
| 159 | if (!latestTurn) { | 166 | if (!latestTurn) { |
| 160 | return null; | 167 | return null; |
| 161 | } | 168 | } |
| ... | @@ -291,6 +298,7 @@ function deriveTurnFolds(input: { | ... | @@ -291,6 +298,7 @@ function deriveTurnFolds(input: { |
| 291 | export function deriveMessagesTimelineRows(input: { | 298 | export function deriveMessagesTimelineRows(input: { |
| 292 | timelineEntries: ReadonlyArray<TimelineEntry>; | 299 | timelineEntries: ReadonlyArray<TimelineEntry>; |
| 293 | latestTurn?: TimelineLatestTurn | null; | 300 | latestTurn?: TimelineLatestTurn | null; |
| 301 | runningTurnId?: TurnId | null; | ||
| 294 | expandedTurnIds?: ReadonlySet<TurnId>; | 302 | expandedTurnIds?: ReadonlySet<TurnId>; |
| 295 | isWorking: boolean; | 303 | isWorking: boolean; |
| 296 | activeTurnStartedAt: string | null; | 304 | activeTurnStartedAt: string | null; |
| ... | @@ -302,7 +310,10 @@ export function deriveMessagesTimelineRows(input: { | ... | @@ -302,7 +310,10 @@ export function deriveMessagesTimelineRows(input: { |
| 302 | input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), | 310 | input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), |
| 303 | ); | 311 | ); |
| 304 | const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries); | 312 | const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries); |
| 305 | const unsettledTurnId = deriveUnsettledTurnId(input.latestTurn ?? null); | 313 | const unsettledTurnId = deriveUnsettledTurnId( |
| 314 | input.latestTurn ?? null, | ||
| 315 | input.runningTurnId ?? null, | ||
| 316 | ); | ||
| 306 | const foldsByAnchorEntryId = deriveTurnFolds({ | 317 | const foldsByAnchorEntryId = deriveTurnFolds({ |
| 307 | timelineEntries: input.timelineEntries, | 318 | timelineEntries: input.timelineEntries, |
| 308 | terminalAssistantMessageIds, | 319 | terminalAssistantMessageIds, |
apps/web/src/components/chat/MessagesTimeline.test.tsx+1| ... | @@ -109,6 +109,7 @@ function buildProps() { | ... | @@ -109,6 +109,7 @@ function buildProps() { |
| 109 | activeTurnStartedAt: null, | 109 | activeTurnStartedAt: null, |
| 110 | listRef: createRef<LegendListRef | null>(), | 110 | listRef: createRef<LegendListRef | null>(), |
| 111 | latestTurn: null, | 111 | latestTurn: null, |
| 112 | runningTurnId: null, | ||
| 112 | turnDiffSummaryByAssistantMessageId: new Map(), | 113 | turnDiffSummaryByAssistantMessageId: new Map(), |
| 113 | routeThreadKey: "environment-local:thread-1", | 114 | routeThreadKey: "environment-local:thread-1", |
| 114 | onOpenTurnDiff: () => {}, | 115 | onOpenTurnDiff: () => {}, |
apps/web/src/components/chat/MessagesTimeline.tsx+4| ... | @@ -153,6 +153,7 @@ interface MessagesTimelineProps { | ... | @@ -153,6 +153,7 @@ interface MessagesTimelineProps { |
| 153 | listRef: React.RefObject<LegendListRef | null>; | 153 | listRef: React.RefObject<LegendListRef | null>; |
| 154 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; | 154 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; |
| 155 | latestTurn: TimelineLatestTurn | null; | 155 | latestTurn: TimelineLatestTurn | null; |
| 156 | runningTurnId: TurnId | null; | ||
| 156 | turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>; | 157 | turnDiffSummaryByAssistantMessageId: Map<MessageId, TurnDiffSummary>; |
| 157 | routeThreadKey: string; | 158 | routeThreadKey: string; |
| 158 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | 159 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| ... | @@ -182,6 +183,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -182,6 +183,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 182 | listRef, | 183 | listRef, |
| 183 | timelineEntries, | 184 | timelineEntries, |
| 184 | latestTurn, | 185 | latestTurn, |
| 186 | runningTurnId, | ||
| 185 | turnDiffSummaryByAssistantMessageId, | 187 | turnDiffSummaryByAssistantMessageId, |
| 186 | routeThreadKey, | 188 | routeThreadKey, |
| 187 | onOpenTurnDiff, | 189 | onOpenTurnDiff, |
| ... | @@ -272,6 +274,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -272,6 +274,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 272 | deriveMessagesTimelineRows({ | 274 | deriveMessagesTimelineRows({ |
| 273 | timelineEntries, | 275 | timelineEntries, |
| 274 | latestTurn, | 276 | latestTurn, |
| 277 | runningTurnId, | ||
| 275 | expandedTurnIds, | 278 | expandedTurnIds, |
| 276 | isWorking, | 279 | isWorking, |
| 277 | activeTurnStartedAt, | 280 | activeTurnStartedAt, |
| ... | @@ -281,6 +284,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -281,6 +284,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 281 | [ | 284 | [ |
| 282 | timelineEntries, | 285 | timelineEntries, |
| 283 | latestTurn, | 286 | latestTurn, |
| 287 | runningTurnId, | ||
| 284 | expandedTurnIds, | 288 | expandedTurnIds, |
| 285 | isWorking, | 289 | isWorking, |
| 286 | activeTurnStartedAt, | 290 | activeTurnStartedAt, |
packages/effect-acp/src/client.test.ts+128-1| ... | @@ -17,18 +17,59 @@ import { it, assert } from "@effect/vitest"; | ... | @@ -17,18 +17,59 @@ import { it, assert } from "@effect/vitest"; |
| 17 | import * as AcpClient from "./client.ts"; | 17 | import * as AcpClient from "./client.ts"; |
| 18 | import * as AcpSchema from "./_generated/schema.gen.ts"; | 18 | import * as AcpSchema from "./_generated/schema.gen.ts"; |
| 19 | import * as AcpError from "./errors.ts"; | 19 | import * as AcpError from "./errors.ts"; |
| 20 | import { encodeJsonl, jsonRpcRequest, jsonRpcResponse } from "./_internal/shared.ts"; | 20 | import { |
| 21 | encodeJsonl, | ||
| 22 | jsonRpcNotification, | ||
| 23 | jsonRpcRequest, | ||
| 24 | jsonRpcResponse, | ||
| 25 | } from "./_internal/shared.ts"; | ||
| 21 | import { makeInMemoryStdio } from "./_internal/stdio.ts"; | 26 | import { makeInMemoryStdio } from "./_internal/stdio.ts"; |
| 22 | 27 | ||
| 23 | const InitializeRequest = jsonRpcRequest("initialize", AcpSchema.InitializeRequest); | 28 | const InitializeRequest = jsonRpcRequest("initialize", AcpSchema.InitializeRequest); |
| 24 | const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse); | 29 | const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse); |
| 25 | const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String })); | 30 | const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String })); |
| 26 | const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); | 31 | const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); |
| 32 | const PromptRequest = jsonRpcRequest("session/prompt", AcpSchema.PromptRequest); | ||
| 33 | const PromptResponse = jsonRpcResponse(AcpSchema.PromptResponse); | ||
| 34 | const decodePromptRequestLine = Schema.decodeEffect(Schema.fromJsonString(PromptRequest)); | ||
| 35 | const XAiPromptCompleteNotification = jsonRpcNotification( | ||
| 36 | "_x.ai/session/prompt_complete", | ||
| 37 | Schema.Struct({ | ||
| 38 | sessionId: Schema.String, | ||
| 39 | promptId: Schema.String, | ||
| 40 | stopReason: Schema.String, | ||
| 41 | agentResult: Schema.NullOr(Schema.Unknown), | ||
| 42 | }), | ||
| 43 | ); | ||
| 44 | const XAiQueueChangedNotification = jsonRpcNotification( | ||
| 45 | "_x.ai/queue/changed", | ||
| 46 | Schema.Struct({ | ||
| 47 | sessionId: Schema.String, | ||
| 48 | entries: Schema.Array(Schema.Unknown), | ||
| 49 | }), | ||
| 50 | ); | ||
| 51 | const XAiSessionsChangedNotification = jsonRpcNotification( | ||
| 52 | "_x.ai/sessions/changed", | ||
| 53 | Schema.Struct({ | ||
| 54 | upserted: Schema.Array(Schema.Unknown), | ||
| 55 | removed: Schema.Array(Schema.Unknown), | ||
| 56 | }), | ||
| 57 | ); | ||
| 27 | const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => | 58 | const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => |
| 28 | path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"), | 59 | path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"), |
| 29 | ); | 60 | ); |
| 30 | const mockPeerArgs = (path: string) => [path]; | 61 | const mockPeerArgs = (path: string) => [path]; |
| 31 | 62 | ||
| 63 | function concatBytes(chunks: ReadonlyArray<Uint8Array>): Uint8Array { | ||
| 64 | const batch = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0)); | ||
| 65 | let offset = 0; | ||
| 66 | for (const chunk of chunks) { | ||
| 67 | batch.set(chunk, offset); | ||
| 68 | offset += chunk.length; | ||
| 69 | } | ||
| 70 | return batch; | ||
| 71 | } | ||
| 72 | |||
| 32 | it.layer(NodeServices.layer)("effect-acp client", (it) => { | 73 | it.layer(NodeServices.layer)("effect-acp client", (it) => { |
| 33 | const makeHandle = (env?: Record<string, string>) => | 74 | const makeHandle = (env?: Record<string, string>) => |
| 34 | Effect.gen(function* () { | 75 | Effect.gen(function* () { |
| ... | @@ -446,4 +487,90 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { | ... | @@ -446,4 +487,90 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { |
| 446 | yield* Scope.close(scope, Exit.void); | 487 | yield* Scope.close(scope, Exit.void); |
| 447 | }), | 488 | }), |
| 448 | ); | 489 | ); |
| 490 | |||
| 491 | it.effect( | ||
| 492 | "routes a standard prompt response after Grok extension notifications in the same batch", | ||
| 493 | () => | ||
| 494 | Effect.gen(function* () { | ||
| 495 | const { stdio, input, output } = yield* makeInMemoryStdio(); | ||
| 496 | const scope = yield* Scope.make(); | ||
| 497 | const acp = yield* AcpClient.make(stdio).pipe(Effect.provideService(Scope.Scope, scope)); | ||
| 498 | |||
| 499 | const promptFiber = yield* acp.agent | ||
| 500 | .prompt({ | ||
| 501 | sessionId: "grok-session-1", | ||
| 502 | prompt: [{ type: "text", text: "run the ls command" }], | ||
| 503 | }) | ||
| 504 | .pipe(Effect.forkScoped); | ||
| 505 | |||
| 506 | const outbound = yield* Queue.take(output); | ||
| 507 | const decodedPrompt = yield* decodePromptRequestLine(outbound); | ||
| 508 | |||
| 509 | const responseBatch = concatBytes( | ||
| 510 | yield* Effect.all([ | ||
| 511 | encodeJsonl(XAiQueueChangedNotification, { | ||
| 512 | jsonrpc: "2.0", | ||
| 513 | method: "_x.ai/queue/changed", | ||
| 514 | params: { sessionId: "grok-session-1", entries: [] }, | ||
| 515 | }), | ||
| 516 | encodeJsonl(XAiPromptCompleteNotification, { | ||
| 517 | jsonrpc: "2.0", | ||
| 518 | method: "_x.ai/session/prompt_complete", | ||
| 519 | params: { | ||
| 520 | sessionId: "grok-session-1", | ||
| 521 | promptId: "prompt-1", | ||
| 522 | stopReason: "end_turn", | ||
| 523 | agentResult: null, | ||
| 524 | }, | ||
| 525 | }), | ||
| 526 | encodeJsonl(XAiSessionsChangedNotification, { | ||
| 527 | jsonrpc: "2.0", | ||
| 528 | method: "_x.ai/sessions/changed", | ||
| 529 | params: { | ||
| 530 | upserted: [ | ||
| 531 | { | ||
| 532 | sessionId: "grok-session-1", | ||
| 533 | title: null, | ||
| 534 | cwd: process.cwd(), | ||
| 535 | isWorktree: false, | ||
| 536 | modelId: "grok-composer-2.5-fast", | ||
| 537 | yolo: false, | ||
| 538 | activity: "idle", | ||
| 539 | resident: true, | ||
| 540 | lastChangeUnixMs: 1_710_000_000_000, | ||
| 541 | origin: { kind: "local" }, | ||
| 542 | }, | ||
| 543 | ], | ||
| 544 | removed: [], | ||
| 545 | }, | ||
| 546 | }), | ||
| 547 | encodeJsonl(PromptResponse, { | ||
| 548 | jsonrpc: "2.0", | ||
| 549 | id: decodedPrompt.id, | ||
| 550 | result: { | ||
| 551 | stopReason: "end_turn", | ||
| 552 | _meta: { | ||
| 553 | sessionId: "grok-session-1", | ||
| 554 | requestId: "prompt-1", | ||
| 555 | promptId: "prompt-1", | ||
| 556 | modelId: "grok-composer-2.5-fast", | ||
| 557 | }, | ||
| 558 | }, | ||
| 559 | }), | ||
| 560 | ]), | ||
| 561 | ); | ||
| 562 | yield* Queue.offer(input, responseBatch); | ||
| 563 | |||
| 564 | assert.deepEqual(yield* Fiber.join(promptFiber), { | ||
| 565 | stopReason: "end_turn", | ||
| 566 | _meta: { | ||
| 567 | sessionId: "grok-session-1", | ||
| 568 | requestId: "prompt-1", | ||
| 569 | promptId: "prompt-1", | ||
| 570 | modelId: "grok-composer-2.5-fast", | ||
| 571 | }, | ||
| 572 | }); | ||
| 573 | yield* Scope.close(scope, Exit.void); | ||
| 574 | }), | ||
| 575 | ); | ||
| 449 | }); | 576 | }); |