From 24a023e5d2193949b88cbb287970d4eb3c980df6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:28:48 -0700 Subject: [PATCH 1/9] fix(orchestration): an OpenCode worker gets its task only once its input box exists Worker start waited tui-idle, which settles on a title holding only the agent's name. OpenCode sets that title before drawing its input box, and a shell auto-title names the pane at spawn, so the brief could be typed into nothing. - composerReadyCaptures: optional per-agent evidence; worker start takes the marker-only lane iff a row cites captures (zcode, opencode, opencode2) - waitForLaunchedAgentComposer picks the lane; Windows-hosted panes on OpenCode's signal stay on tui-idle until a ConPTY capture exists (temporary) - waitForFreshWorkerComposer returns the tui-idle result shape, so the wait-for-setup outcome is recorded (fixes ZCode's receipt stuck at running) - render-cursor-after-bracketed-paste is revoked on ESC[?2004l - scanner seam fix: carried chars no longer re-searched for the marker - four timed OpenCode boot captures (1.18.32 x3, 2.0.18) committed --- ...e-1-18-32-timed-boot-hidden-pane.meta.json | 9 + ...1-18-32-timed-boot-hidden-pane.timing.json | 1 + ...pencode-1-18-32-timed-boot-hidden-pane.txt | 1 + ...opencode-1-18-32-timed-boot-slow.meta.json | 9 + ...encode-1-18-32-timed-boot-slow.timing.json | 1 + .../opencode-1-18-32-timed-boot-slow.txt | 1 + ...ncode-1-18-32-timed-first-launch.meta.json | 9 + ...ode-1-18-32-timed-first-launch.timing.json | 1 + .../opencode-1-18-32-timed-first-launch.txt | 1 + ...de-2-0-18-timed-boot-hidden-pane.meta.json | 9 + ...-2-0-18-timed-boot-hidden-pane.timing.json | 1 + ...opencode-2-0-18-timed-boot-hidden-pane.txt | 1 + .../agent-transcript-replay-test-harness.ts | 35 ++++ .../runtime/codex-quiet-ready-screen.test.ts | 20 +- ...draft-paste-ready-scanner-chunking.test.ts | 191 ++++++++++++++++++ .../launched-agent-composer-readiness.test.ts | 190 +++++++++++++++++ .../launched-agent-composer-readiness.ts | 49 +++++ ...pencode-worker-composer-transcript.test.ts | 102 ++++++++++ .../orca-runtime-activate-managed-worktree.ts | 19 +- .../launched-agent-worker-readiness.test.ts | 91 +++++++++ .../worker/local-worker-start.ts | 16 +- .../worker-start-structured-setup-gate.ts | 4 +- .../worker/workers-new-worktree.test.ts | 27 +++ .../worker/zcode-worker-readiness.test.ts | 44 ---- .../zcode-readiness-transcript.test.ts | 11 +- ...agent-paste-draft-readiness-budget.test.ts | 4 +- src/shared/draft-paste-ready-scanner.test.ts | 37 +++- src/shared/draft-paste-ready-scanner.ts | 35 +++- src/shared/tui-agent-config-types.ts | 5 + src/shared/tui-agent-config.ts | 13 +- 30 files changed, 840 insertions(+), 97 deletions(-) create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt create mode 100644 src/main/runtime/draft-paste-ready-scanner-chunking.test.ts create mode 100644 src/main/runtime/launched-agent-composer-readiness.test.ts create mode 100644 src/main/runtime/launched-agent-composer-readiness.ts create mode 100644 src/main/runtime/opencode-worker-composer-transcript.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/worker/launched-agent-worker-readiness.test.ts delete mode 100644 src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json new file mode 100644 index 000000000000..c7e2132fa288 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:40:33.073309Z", + "platform": "darwin", + "command": ["opencode"], + "cols": 120, + "rows": 40, + "note": "1.18.32 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: answered like a hidden Orca pane (silent on OSC colour queries), as a background worker pane is. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'headless' = Orca's hidden-pane replies, silent on OSC colour queries. Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 42.8 s; no bytes after the last chunk. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json new file mode 100644 index 000000000000..0701a967c642 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":42761.8,"chunks":[[785.0,205],[786.9,71],[791.9,8],[793.5,57],[793.9,32],[794.0,16],[794.0,16],[794.1,24],[794.2,24],[794.2,24],[794.3,36],[794.4,24],[794.5,24],[795.2,1024],[795.8,1024],[796.3,1024],[796.7,1024],[796.9,1024],[797.2,961],[811.9,1024],[812.5,1024],[813.1,1024],[813.6,1024],[814.1,1024],[814.7,937],[2523.9,1024],[2524.9,1024],[2525.2,1024],[2525.5,1024],[2525.8,1024],[2526.1,1024],[2526.3,353],[2541.6,13],[2609.5,934],[2610.0,938],[2610.6,871],[2611.3,584]]} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt new file mode 100644 index 000000000000..903a8f0f6968 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "What is the tech stack of this project?" ┃ ┃ Build · Big Pickle OpenCode Zen ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀tab agentsctrl+p commands● Tip Run /connect to add an AI provider and start coding/Users/USER1/orca-qa/b9b-capture/work1.18.32]12;#eeeeee[1 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json new file mode 100644 index 000000000000..b05bbf658fe7 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:39:12.532885Z", + "platform": "darwin", + "command": ["opencode"], + "cols": 120, + "rows": 40, + "note": "1.18.32 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: the slowest recorded boot: 3.9 s of silence after bracketed paste is enabled, and the longest gap (161 ms) between the bare OpenCode title and the input box. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'xterm' = xterm.js 6.0 replies incl. OSC 10/11/12/4 colours (a visible Orca pane). Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 76.9 s; no bytes after the last chunk. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json new file mode 100644 index 000000000000..25aa23bb5bd3 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":76886.4,"chunks":[[2454.9,276],[2478.3,8],[2513.8,1024],[2518.9,1024],[2531.6,1024],[2539.1,1024],[2581.2,1024],[2583.9,1024],[2587.4,411],[2593.0,1024],[2602.3,1024],[2607.8,1024],[2621.8,1024],[2622.5,1024],[2623.3,937],[6479.2,1024],[6485.0,1024],[6487.5,1024],[6490.0,1024],[6499.8,1024],[6502.7,1024],[6509.0,353],[6538.4,13],[6685.0,934],[6686.4,938],[6697.9,871],[6699.1,584]]} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt new file mode 100644 index 000000000000..748053e1c3ee --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?1004h]4;0;?]4;1;?]4;2;?]4;3;?]4;4;?]4;5;?]4;6;?]4;7;?]4;8;?]4;9;?]4;10;?]4;11;?]4;12;?]4;13;?]4;14;?]4;15;?]10;?]11;?]12;?]13;?]14;?]15;?]16;?]17;?]19;?[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix a TODO in the codebase" ┃ ┃ Build · Big Pickle OpenCode Zen ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀tab agentsctrl+p commands● Tip Run /connect to add an AI provider and start coding/Users/USER1/orca-qa/b9b-capture/work1.18.32]12;#eeeeee[1 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json new file mode 100644 index 000000000000..e0ad565f9350 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:33:44.169427Z", + "platform": "darwin", + "command": ["opencode"], + "cols": 120, + "rows": 40, + "note": "1.18.32 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: first launch on an empty home: no first-run dialog appears before the input box. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'xterm' = xterm.js 6.0 replies incl. OSC 10/11/12/4 colours (a visible Orca pane). Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 14.9 s; no bytes after the last chunk. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json new file mode 100644 index 000000000000..9c1d94e0a7a6 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":14932.3,"chunks":[[894.6,205],[897.3,71],[906.1,8],[909.0,57],[909.3,32],[909.4,16],[909.5,16],[909.6,24],[909.6,24],[909.8,24],[909.9,36],[910.0,24],[910.1,24],[911.8,134],[915.0,1024],[916.4,1024],[917.0,1024],[917.5,1024],[918.0,1024],[918.6,1024],[933.4,1024],[934.1,1024],[934.7,1024],[935.3,1024],[935.8,1024],[936.4,937],[2731.2,1024],[2731.8,1024],[2732.2,1024],[2732.7,1024],[2733.1,1024],[2734.0,1024],[2734.4,353],[2766.9,13],[2847.0,934],[2847.6,938],[2848.1,871],[2848.6,584]]} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt new file mode 100644 index 000000000000..2baf054700fc --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h]4;0;?]4;1;?]4;2;?]4;3;?]4;4;?]4;5;?]4;6;?]4;7;?]4;8;?]4;9;?]4;10;?]4;11;?]4;12;?]4;13;?]4;14;?]4;15;?]10;?]11;?]12;?]13;?]14;?]15;?]16;?]17;?]19;?[?2026h[?25l                                        [?2026l[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "What is the tech stack of this project?" ┃ ┃ Build · Big Pickle OpenCode Zen ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀tab agentsctrl+p commands● Tip Run /connect to add an AI provider and start coding/Users/USER1/orca-qa/b9b-capture/work1.18.32]12;#eeeeee[1 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json new file mode 100644 index 000000000000..5399222bf7ac --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:50:47.405221Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.18 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: OpenCode 2 answered like a hidden Orca pane; show-cursor repeats on every frame after the box. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'headless' = Orca's hidden-pane replies, silent on OSC colour queries. Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 41.5 s; no bytes after the last chunk. Run as `opencode --standalone` from a 2.0.18 binary; Orca's opencode2 row launches `opencode2 --standalone`. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json new file mode 100644 index 000000000000..2a95872c1809 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":41502.8,"chunks":[[307.5,280],[316.3,1024],[316.7,1024],[317.0,1024],[317.4,1024],[317.7,1024],[318.0,1022],[318.3,164],[318.3,24],[318.3,24],[326.8,1024],[327.5,1024],[328.0,1024],[328.6,1024],[329.1,1024],[329.7,937],[1339.7,1024],[1340.4,1024],[1341.1,1024],[1341.8,1024],[1342.5,1024],[1343.1,1024],[1343.8,353],[1348.0,13],[1387.9,934],[1388.4,938],[1388.7,832],[1418.2,483],[1436.2,483],[1453.8,488],[1471.8,497],[1489.7,498],[1508.0,504],[1525.8,504],[1543.2,504],[1560.9,504]]} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt new file mode 100644 index 000000000000..b2b9cd01dfee --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h[?2027h[?2004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix a TODO in the codebase" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/Users/USER1/orca-qa/b9b-capture/workshift+tab agentsctrl+p commands2.0.18]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/agent-transcript-replay-test-harness.ts b/src/main/runtime/agent-transcript-replay-test-harness.ts index 5b57656ab4ac..aa488866c62e 100644 --- a/src/main/runtime/agent-transcript-replay-test-harness.ts +++ b/src/main/runtime/agent-transcript-replay-test-harness.ts @@ -16,6 +16,41 @@ export function readRuntimeFixture(name: string): string { return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') } +export type TimedRuntimeFixture = { + /** The recorded PTY reads, in order; they concatenate to the whole `.txt`. */ + chunks: string[] + /** ms since spawn at which each read arrived. */ + times: number[] + promptSentAtMs?: number + /** When recording stopped; no bytes arrived between the last read and this. */ + recordedUntilMs?: number +} + +/** `.timing.json` holds each read as [ms since spawn, UTF-16 length]. */ +export function readTimedRuntimeFixture(name: string): TimedRuntimeFixture { + const data = readRuntimeFixture(name) + const timing: { + chunks: [number, number][] + promptSentAtMs?: number + recordedUntilMs?: number + } = JSON.parse(readFileSync(join(__dirname, '__fixtures__', `${name}.timing.json`), 'utf8')) + const chunks: string[] = [] + let offset = 0 + for (const [, length] of timing.chunks) { + chunks.push(data.slice(offset, offset + length)) + offset += length + } + if (offset !== data.length) { + throw new Error(`${name}.timing.json covers ${offset} of ${data.length} chars`) + } + return { + chunks, + times: timing.chunks.map(([at]) => at), + ...(timing.promptSentAtMs !== undefined ? { promptSentAtMs: timing.promptSentAtMs } : {}), + ...(timing.recordedUntilMs !== undefined ? { recordedUntilMs: timing.recordedUntilMs } : {}) + } +} + /** A string is cut into fixed 64-char chunks; an array replays the recorded PTY chunks as-is. */ export async function* replayTranscript( data: string | readonly string[], diff --git a/src/main/runtime/codex-quiet-ready-screen.test.ts b/src/main/runtime/codex-quiet-ready-screen.test.ts index 223eef2d858e..d51cd3c7da6a 100644 --- a/src/main/runtime/codex-quiet-ready-screen.test.ts +++ b/src/main/runtime/codex-quiet-ready-screen.test.ts @@ -1,10 +1,9 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { TuiAgent } from '../../shared/tui-agent' import { createTranscriptPane } from './agent-transcript-pane-test-harness' import { readRuntimeFixture, + readTimedRuntimeFixture, replayTranscript, type TranscriptReplayFrame } from './agent-transcript-replay-test-harness' @@ -204,21 +203,12 @@ describe('Codex composer ready screen, frame by frame', () => { }) }) -type Timing = { promptSentAtMs: number; chunks: [number, number][] } - function readTimedFixture(name: string): { chunks: string[]; times: number[]; promptAt: number } { - const data = readRuntimeFixture(name) - const timing: Timing = JSON.parse( - readFileSync(join(__dirname, '__fixtures__', `${name}.timing.json`), 'utf8') - ) - const chunks: string[] = [] - let offset = 0 - for (const [, length] of timing.chunks) { - chunks.push(data.slice(offset, offset + length)) - offset += length + const { chunks, times, promptSentAtMs } = readTimedRuntimeFixture(name) + if (promptSentAtMs === undefined) { + throw new Error(`${name}.timing.json has no promptSentAtMs`) } - expect(offset).toBe(data.length) - return { chunks, times: timing.chunks.map(([at]) => at), promptAt: timing.promptSentAtMs } + return { chunks, times, promptAt: promptSentAtMs } } describe('the quiet lane over recorded chunk timing (default animations)', () => { diff --git a/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts new file mode 100644 index 000000000000..8fbbf19d552d --- /dev/null +++ b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts @@ -0,0 +1,191 @@ +/** + * The draft-paste scanner must answer the same way however the PTY splits a stream into reads. + * Every committed capture whose signal revokes its anchor is replayed whole, one char at a time, + * in its recorded reads (where timed) and in seeded random chunkings, and each must turn ready on + * the read holding the offset a plain string walk finds. A regression guard for grok, DSH and + * ZCode (their markers are one char, so the seam fix cannot move them), and the proof for OpenCode. + */ + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import type { DraftPasteReadySignal } from '../../shared/tui-agent-config' +import { GROK_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-startup-pty-trace' +import { GROK_INLINE_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-inline-startup-pty-trace' +import type { GrokStartupTraceChunk } from '../../shared/__fixtures__/grok-startup-pty-trace' +import { readRuntimeFixture, readTimedRuntimeFixture } from './agent-transcript-replay-test-harness' + +type Walk = { anchor: string; end: string; marker: string } + +const ALT_SCREEN: Walk = { anchor: '\x1b[?1049h', end: '\x1b[?1049l', marker: '' } +const WALKS: Partial> = { + 'render-cursor-after-bracketed-paste': { + anchor: '\x1b[?2004h', + end: '\x1b[?2004l', + marker: '\x1b[?25h' + }, + 'grok-composer-prompt': { ...ALT_SCREEN, marker: '❯' }, + 'dsh-composer-prompt': { ...ALT_SCREEN, marker: '❯' }, + 'zcode-composer-prompt': { ...ALT_SCREEN, marker: '╭' } +} + +// Recorded zsh shape: the prompt enables bracketed paste and accept-line disables it before exec. +// The launcher's cursor toggle after that is synthetic, standing in for any spinner. +const ZSH_LAUNCH_PROLOGUE = + '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' + +const OPENCODE_TIMED = [ + 'opencode-1-18-32-timed-boot-slow', + 'opencode-1-18-32-timed-boot-hidden-pane', + 'opencode-1-18-32-timed-first-launch', + 'opencode-2-0-18-timed-boot-hidden-pane' +] + +type Case = { name: string; signal: DraftPasteReadySignal; data: string; reads?: string[] } + +function readPtyTranscript(name: string): string { + return readFileSync( + join(__dirname, '..', 'daemon', '__fixtures__', 'pty-transcripts', name), + 'utf8' + ) +} + +function grokCase(name: string, trace: GrokStartupTraceChunk[]): Case { + const reads = trace.map((chunk) => chunk.data ?? 'x'.repeat(chunk.bytes ?? 0)) + return { name, signal: 'grok-composer-prompt', data: reads.join(''), reads } +} + +const CASES: Case[] = [ + grokCase('grok 1.0.0 alt screen', GROK_STARTUP_PTY_TRACE), + grokCase('grok 1.0.0 inline', GROK_INLINE_STARTUP_PTY_TRACE), + { + name: 'dsh-tui-ready-no-key', + signal: 'dsh-composer-prompt', + data: readRuntimeFixture('dsh-tui-ready-no-key') + }, + { + name: 'zcode-composer-ready', + signal: 'zcode-composer-prompt', + data: readRuntimeFixture('zcode-composer-ready') + }, + { + name: 'opencode (untimed pty transcript)', + signal: 'render-cursor-after-bracketed-paste', + data: readPtyTranscript('opencode.txt') + }, + ...OPENCODE_TIMED.flatMap((name): Case[] => { + const { chunks } = readTimedRuntimeFixture(name) + return [ + { name, signal: 'render-cursor-after-bracketed-paste', data: chunks.join(''), reads: chunks }, + { + name: `${name} behind a zsh launch`, + signal: 'render-cursor-after-bracketed-paste', + data: ZSH_LAUNCH_PROLOGUE + chunks.join(''), + reads: [ZSH_LAUNCH_PROLOGUE, ...chunks] + } + ] + }) +] + +/** End offset of the first marker seen while the anchor is held, walking the whole string. */ +function walkReadyEnd(data: string, { anchor, end, marker }: Walk): number | null { + let cursor = 0 + for (;;) { + const enter = data.indexOf(anchor, cursor) + if (enter === -1) { + return null + } + cursor = enter + anchor.length + const leave = data.indexOf(end, cursor) + const found = data.indexOf(marker, cursor) + if (found !== -1 && (leave === -1 || found + marker.length <= leave)) { + return found + marker.length + } + if (leave === -1) { + return null + } + cursor = leave + end.length + } +} + +/** [start, end) of the first read the scanner reports ready on, or null. */ +function scanReadyRead(signal: DraftPasteReadySignal, reads: string[]): [number, number] | null { + const scanner = createDraftPasteReadyScanner(signal) + let offset = 0 + for (const read of reads) { + if (scanner.observe(read).ready) { + return [offset, offset + read.length] + } + offset += read.length + } + return null +} + +function splitAt(data: string, sizes: () => number): string[] { + const reads: string[] = [] + for (let offset = 0; offset < data.length;) { + const size = sizes() + reads.push(data.slice(offset, offset + size)) + offset += size + } + return reads +} + +// mulberry32: a fixed seed keeps every chunking reproducible. +function seededSizes(seed: number, max: number): () => number { + let state = seed + return () => { + state = (state + 0x6d2b79f5) | 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return 1 + Math.floor((((t ^ (t >>> 14)) >>> 0) / 4294967296) * max) + } +} + +function chunkings(testCase: Case): Map { + const { data } = testCase + const result = new Map([ + ['whole', [data]], + ['one char per read', splitAt(data, () => 1)] + ]) + if (testCase.reads) { + result.set('recorded reads', testCase.reads) + } + for (let seed = 0; seed < 12; seed += 1) { + const max = seed % 2 === 0 ? 16 : 2048 + result.set(`random chunking ${seed}`, splitAt(data, seededSizes(seed + 1, max))) + } + return result +} + +describe('draft-paste readiness does not depend on how the stream is chunked', () => { + it.each(CASES.map((testCase) => [testCase.name, testCase] as const))('%s', (_, testCase) => { + const walk = WALKS[testCase.signal] + if (!walk) { + throw new Error(`no reference walk for ${testCase.signal}`) + } + const expected = walkReadyEnd(testCase.data, walk) + for (const [label, reads] of chunkings(testCase)) { + const readyRead = scanReadyRead(testCase.signal, reads) + if (expected === null) { + expect(readyRead, label).toBeNull() + continue + } + expect(readyRead, label).not.toBeNull() + const [start, end] = readyRead! + expect( + start < expected && expected <= end, + `${label}: [${start}, ${end}) vs ${expected}` + ).toBe(true) + } + }) + + it.each(OPENCODE_TIMED)('%s: a zsh launch in front moves nothing but the offset', (name) => { + const walk = WALKS['render-cursor-after-bracketed-paste']! + const data = readTimedRuntimeFixture(name).chunks.join('') + const alone = walkReadyEnd(data, walk) + expect(alone).not.toBeNull() + expect(walkReadyEnd(ZSH_LAUNCH_PROLOGUE + data, walk)).toBe(ZSH_LAUNCH_PROLOGUE.length + alone!) + }) +}) diff --git a/src/main/runtime/launched-agent-composer-readiness.test.ts b/src/main/runtime/launched-agent-composer-readiness.test.ts new file mode 100644 index 000000000000..0b5589ceb9f1 --- /dev/null +++ b/src/main/runtime/launched-agent-composer-readiness.test.ts @@ -0,0 +1,190 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import type { TuiAgent } from '../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { + readRuntimeFixture, + readTimedRuntimeFixture, + replayTranscript +} from './agent-transcript-replay-test-harness' +import { + getLaunchedAgentReadinessLane, + type LaunchedAgentReadinessLane +} from './launched-agent-composer-readiness' + +// Why a full Record: adding a TuiAgent fails to compile here until someone decides whether its +// worker start waits for a captured input-box marker, and a row that gains or loses evidence +// shows up in review. +const EXPECTED_LANES: Record = { + zcode: 'composer-marker', + opencode: 'composer-marker', + opencode2: 'composer-marker', + // mimo-code shares OpenCode's signal by parity only; no mimo boot has been recorded. + 'mimo-code': 'tui-idle', + // Grok's inline mode never fires its marker; its marker is not proven at the box for every mode. + grok: 'tui-idle', + dsh: 'tui-idle', + codex: 'tui-idle', + cursor: 'tui-idle', + pi: 'tui-idle', + omp: 'tui-idle', + droid: 'tui-idle', + hermes: 'tui-idle', + devin: 'tui-idle', + claude: 'tui-idle', + 'claude-agent-teams': 'tui-idle', + openclaude: 'tui-idle', + gemini: 'tui-idle', + antigravity: 'tui-idle', + aider: 'tui-idle', + openclaw: 'tui-idle', + copilot: 'tui-idle', + muse: 'tui-idle', + qoder: 'tui-idle', + codebuddy: 'tui-idle', + autohand: 'tui-idle', + ante: 'tui-idle', + trae: 'tui-idle', + 'prime-agent': 'tui-idle', + goose: 'tui-idle', + amp: 'tui-idle', + kilo: 'tui-idle', + kiro: 'tui-idle', + crush: 'tui-idle', + aug: 'tui-idle', + cline: 'tui-idle', + codebuff: 'tui-idle', + freebuff: 'tui-idle', + 'command-code': 'tui-idle', + continue: 'tui-idle', + kimi: 'tui-idle', + 'mistral-vibe': 'tui-idle', + 'qwen-code': 'tui-idle', + rovo: 'tui-idle' +} + +const FIXTURES = join(__dirname, '__fixtures__') +const OPENCODE_PLACEHOLDER = 'Ask anything' +const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' + +const CITED_CAPTURES = Object.entries(TUI_AGENT_CONFIG).flatMap(([agent, row]) => + (row.composerReadyCaptures ?? []).map((capture) => [agent, capture] as const) +) + +function isTuiAgent(agent: string): agent is TuiAgent { + return agent in TUI_AGENT_CONFIG +} + +function readSignal(agent: string) { + if (!isTuiAgent(agent)) { + throw new Error(`${agent} is not a TuiAgent`) + } + const signal = TUI_AGENT_CONFIG[agent].draftPasteReadySignal + if (!signal) { + throw new Error(`${agent} cites composer-ready captures but has no draftPasteReadySignal`) + } + return signal +} + +/** Index of the first read the scanner reports ready on, or -1. */ +function firstReadyRead(agent: string, reads: readonly string[]): number { + const scanner = createDraftPasteReadyScanner(readSignal(agent)) + return reads.findIndex((read) => scanner.observe(read).ready) +} + +function readReads(capture: string): string[] { + if (existsSync(join(FIXTURES, `${capture}.timing.json`))) { + return readTimedRuntimeFixture(capture).chunks + } + const data = readRuntimeFixture(capture) + const reads: string[] = [] + for (let offset = 0; offset < data.length; offset += 4096) { + reads.push(data.slice(offset, offset + 4096)) + } + return reads +} + +describe('which lane a freshly launched worker waits on', () => { + it.each(Object.entries(EXPECTED_LANES))('%s: %s', (agent, lane) => { + if (!isTuiAgent(agent)) { + throw new Error(`${agent} is not a TuiAgent`) + } + expect(getLaunchedAgentReadinessLane(agent, () => 'darwin')).toBe(lane) + expect(getLaunchedAgentReadinessLane(agent, () => 'linux')).toBe(lane) + }) + + it.each(['opencode', 'opencode2'] as const)( + '%s on a Windows-hosted pane keeps tui-idle until a ConPTY capture exists (temporary)', + (agent) => { + expect(getLaunchedAgentReadinessLane(agent, () => 'win32')).toBe('tui-idle') + } + ) + + it('keeps ZCode on its marker on Windows, whose marker is anchored on the alternate screen', () => { + expect(getLaunchedAgentReadinessLane('zcode', () => 'win32')).toBe('composer-marker') + }) + + it('asks for the pane host only for rows that carry evidence', () => { + expect( + getLaunchedAgentReadinessLane('claude', () => { + throw new Error('the host must not be asked') + }) + ).toBe('tui-idle') + }) +}) + +describe('every capture a row cites proves its input-box marker', () => { + it('cites at least the rows worker start moved', () => { + expect(CITED_CAPTURES.map(([agent]) => agent)).toEqual( + expect.arrayContaining(['zcode', 'opencode', 'opencode2']) + ) + }) + + it.each(CITED_CAPTURES)('%s: %s is committed with its sidecar', (_agent, capture) => { + expect(existsSync(join(FIXTURES, `${capture}.txt`))).toBe(true) + const meta: { platform?: string; command?: string[]; note?: string } = JSON.parse( + readFileSync(join(FIXTURES, `${capture}.meta.json`), 'utf8') + ) + expect(meta.platform).toEqual(expect.any(String)) + expect(meta.command?.length).toBeGreaterThan(0) + expect(meta.note).toEqual(expect.any(String)) + }) + + it.each(CITED_CAPTURES)("%s: the row's current signal fires on %s", (agent, capture) => { + expect(firstReadyRead(agent, readReads(capture))).toBeGreaterThanOrEqual(0) + }) + + it.each( + CITED_CAPTURES.filter(([agent]) => readSignal(agent) === 'render-cursor-after-bracketed-paste') + )( + '%s: the first ready read in %s is the one that shows the input box', + async (agent, capture) => { + const meta: { cols: number; rows: number } = JSON.parse( + readFileSync(join(FIXTURES, `${capture}.meta.json`), 'utf8') + ) + const { chunks } = readTimedRuntimeFixture(capture) + const data = chunks.join('') + // OpenCode paints its box in one synchronized update, shown when that update ends. + const boxEnd = data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER)) + let boxRead = -1 + for (let index = 0, end = 0; index < chunks.length && boxRead === -1; index += 1) { + end += chunks[index].length + boxRead = end >= boxEnd + SYNCHRONIZED_UPDATE_END.length ? index : -1 + } + let placeholderRead = -1 + let read = 0 + for await (const frame of replayTranscript(chunks, meta.cols, meta.rows)) { + if (frame.screenLines.some((line) => line.includes(OPENCODE_PLACEHOLDER))) { + placeholderRead = read + break + } + read += 1 + } + expect(placeholderRead).toBeGreaterThanOrEqual(0) + expect(boxRead).toBeGreaterThanOrEqual(placeholderRead) + expect(firstReadyRead(agent, chunks)).toBe(boxRead) + } + ) +}) diff --git a/src/main/runtime/launched-agent-composer-readiness.ts b/src/main/runtime/launched-agent-composer-readiness.ts new file mode 100644 index 000000000000..7a36c00e47e6 --- /dev/null +++ b/src/main/runtime/launched-agent-composer-readiness.ts @@ -0,0 +1,49 @@ +/** + * The one answer to "has the agent Orca just launched opened its input box?" for a worker's first + * dispatch. An agent whose row cites captures of its composer marker (`composerReadyCaptures`) + * waits for that marker alone: a title holding only the agent's name can arrive before the box + * (OpenCode's own, or a shell auto-title), and a task typed then is lost. Every other agent keeps + * the `tui-idle` wait. + */ + +import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts' +import type { TuiAgent } from '../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import type { OrcaRuntimeService } from './orca-runtime' + +export type LaunchedAgentReadinessLane = 'composer-marker' | 'tui-idle' + +export type LaunchedAgentReadinessRuntime = Pick< + OrcaRuntimeService, + 'waitForTerminal' | 'waitForFreshWorkerComposer' | 'getTerminalPtyHostPlatform' +> + +export function getLaunchedAgentReadinessLane( + agent: TuiAgent, + getPtyHostPlatform: () => NodeJS.Platform +): LaunchedAgentReadinessLane { + const row = TUI_AGENT_CONFIG[agent] + if (!row.composerReadyCaptures?.length) { + return 'tui-idle' + } + // Temporary: no capture through Orca's bundled conpty.dll shows this marker at the box yet. + if ( + row.draftPasteReadySignal === 'render-cursor-after-bracketed-paste' && + getPtyHostPlatform() === 'win32' + ) { + return 'tui-idle' + } + return 'composer-marker' +} + +export function waitForLaunchedAgentComposer( + runtime: LaunchedAgentReadinessRuntime, + handle: string, + agent: TuiAgent, + timeoutMs: number +): Promise { + return getLaunchedAgentReadinessLane(agent, () => runtime.getTerminalPtyHostPlatform(handle)) === + 'composer-marker' + ? runtime.waitForFreshWorkerComposer(handle, agent, timeoutMs) + : runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs }) +} diff --git a/src/main/runtime/opencode-worker-composer-transcript.test.ts b/src/main/runtime/opencode-worker-composer-transcript.test.ts new file mode 100644 index 000000000000..f3f3f71745af --- /dev/null +++ b/src/main/runtime/opencode-worker-composer-transcript.test.ts @@ -0,0 +1,102 @@ +/** + * OpenCode worker start, replayed through the runtime at the recorded read times. The pane first + * shows a zsh launch that names it `opencode` (a shell auto-title, as oh-my-zsh's preexec writes), + * so a wait that trusts a bare-name title has its answer before OpenCode draws anything. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TuiAgent } from '../../shared/tui-agent' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { readTimedRuntimeFixture } from './agent-transcript-replay-test-harness' +import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +// Recorded zsh shape (prompt enables bracketed paste, accept-line disables it); the auto-title +// and the launcher's cursor toggle are synthetic. +const ZSH_LAUNCH = + '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' +const OPENCODE_PLACEHOLDER = 'Ask anything' +const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' + +const RUNS: [string, TuiAgent][] = [ + ['opencode-1-18-32-timed-boot-slow', 'opencode'], + ['opencode-1-18-32-timed-boot-hidden-pane', 'opencode'], + ['opencode-1-18-32-timed-first-launch', 'opencode'], + ['opencode-2-0-18-timed-boot-hidden-pane', 'opencode2'] +] + +/** The read that ends the synchronized update drawing OpenCode's input box. */ +function boxRead(chunks: string[]): number { + const data = chunks.join('') + const boxEnd = + data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER)) + + SYNCHRONIZED_UPDATE_END.length + let end = 0 + return chunks.findIndex((chunk) => (end += chunk.length) >= boxEnd) +} + +async function replay(name: string, agent: TuiAgent) { + const { chunks, times } = readTimedRuntimeFixture(name) + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'opencode', + launchAgent: agent, + size: { cols: 120, rows: 40 }, + data: '' + }) + vi.spyOn(runtime, 'getTerminalPtyHostPlatform').mockReturnValue('darwin') + vi.useFakeTimers() + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ZSH_LAUNCH, Date.now()) + const settledAt: { composer: number | null; tuiIdle: number | null } = { + composer: null, + tuiIdle: null + } + let read = -1 + const composer = waitForLaunchedAgentComposer(runtime, handle, agent, 60_000) + void composer.then(() => (settledAt.composer ??= read)) + const tuiIdle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) + void tuiIdle.then( + () => (settledAt.tuiIdle ??= read), + () => {} + ) + let now = 0 + await vi.advanceTimersByTimeAsync(0) + for (const [index, chunk] of chunks.entries()) { + await vi.advanceTimersByTimeAsync(Math.max(0, times[index] - now)) + now = times[index] + read = index + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) + await vi.advanceTimersByTimeAsync(0) + } + return { settledAt, composer, boxRead: boxRead(chunks) } +} + +describe('an OpenCode worker gets its task only once its input box exists', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(RUNS)( + '%s: the worker-start lane settles on the read that shows the box', + async (name, agent) => { + const { settledAt, composer, boxRead } = await replay(name, agent) + await expect(composer).resolves.toMatchObject({ satisfied: true }) + expect(settledAt.composer).toBe(boxRead) + } + ) + + it.each(RUNS)( + '%s: the bare-name tui-idle wait main used would have settled before the box', + async (name, agent) => { + const { settledAt, boxRead } = await replay(name, agent) + expect(settledAt.tuiIdle).not.toBeNull() + expect(settledAt.tuiIdle!).toBeLessThan(boxRead) + } + ) +}) diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 1629c5299216..587ccd6217dd 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -4,6 +4,7 @@ import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import { navigationTargetsClients, navigationTargetsHost } from '../../shared/runtime-navigation' import { getRepoExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' +import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts' import type { TuiAgent } from '../../shared/tui-agent' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { @@ -177,14 +178,18 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM pasteWorktreeStartupDraftWhenReady(this.getWorktreeStartupReadinessHost(), handle, draft) } + /** Platform of the pty transport behind a handle: Windows for a local WSL pane, the remote OS over SSH. */ + getTerminalPtyHostPlatform(handle: string): NodeJS.Platform { + return this.getPtyWriteHostPlatform(this.getLiveTerminalPtyId(handle)) + } + /** Only for a newly launched worker, before its first dispatch input. */ async waitForFreshWorkerComposer( handle: string, agent: TuiAgent, timeoutMs: number - ): Promise { - const initialPtyId = - this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId + ): Promise { + const initialPtyId = this.getLiveTerminalPtyId(handle) const ptyId = await waitForWorktreeStartupDraft( { ...this.getWorktreeStartupReadinessHost(), getPtyId: () => initialPtyId }, handle, @@ -198,6 +203,14 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM if (!this.ptysById.get(ptyId)?.connected) { throw new Error('terminal_handle_stale') } + // The tui-idle result it stands in for, so callers record the wait-for-setup outcome too. + return { handle, condition: 'tui-idle', satisfied: true, status: 'running', exitCode: null } + } + + private getLiveTerminalPtyId(handle: string): string { + return ( + this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId + ) } protected sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/launched-agent-worker-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/launched-agent-worker-readiness.test.ts new file mode 100644 index 000000000000..7b8904e7c0dc --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/launched-agent-worker-readiness.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-terminal-contracts' +import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' + +const COMPOSER_READY: RuntimeTerminalWait = { + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null +} + +describe('first dispatch readiness of a freshly launched worker', () => { + const h = createOrchestrationWorkerReleaseHarness() + afterEach(() => h.cleanup()) + + it.each(['zcode', 'opencode', 'opencode2'] as const)( + '%s waits for its input box before delivering exactly one dispatch', + async (agent) => { + h.setup() + vi.spyOn(h.runtime, 'getTerminalPtyHostPlatform').mockReturnValue('darwin') + const gate = h.deferred() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) + const pending = h.startWorker({ agent }) + await vi.waitFor(() => + expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( + 'term_worker', + agent, + 60_000 + ) + ) + expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + gate.resolve(COMPOSER_READY) + await pending + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + } + ) + + it.each(['codex', 'claude', 'grok', 'mimo-code'] as const)( + '%s, with no captured input-box marker, keeps the tui-idle wait', + async (agent) => { + h.setup() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer') + await h.startWorker({ agent }) + expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() + expect(h.runtime.waitForTerminal).toHaveBeenCalledWith( + 'term_worker', + expect.objectContaining({ condition: 'tui-idle' }) + ) + } + ) + + it.each(['opencode', 'opencode2'] as const)( + '%s on a Windows-hosted pane keeps the tui-idle wait until a ConPTY capture exists', + async (agent) => { + h.setup() + vi.spyOn(h.runtime, 'getTerminalPtyHostPlatform').mockReturnValue('win32') + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer') + await h.startWorker({ agent }) + expect(h.runtime.getTerminalPtyHostPlatform).toHaveBeenCalledWith('term_worker') + expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() + expect(h.runtime.waitForTerminal).toHaveBeenCalledWith( + 'term_worker', + expect.objectContaining({ condition: 'tui-idle' }) + ) + } + ) + + it('keeps reused terminals on the normal idle wait', async () => { + h.setup() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer') + await h.startWorker({ terminal: 'term_worker' }) + expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() + expect(h.runtime.waitForTerminal).toHaveBeenCalledWith( + 'term_worker', + expect.objectContaining({ condition: 'tui-idle' }) + ) + }) + + it.each(['zcode', 'opencode'] as const)( + '%s never delivers a task after a startup timeout', + async (agent) => { + h.setup() + vi.spyOn(h.runtime, 'getTerminalPtyHostPlatform').mockReturnValue('darwin') + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockRejectedValue(new Error('timeout')) + await expect(h.startWorker({ agent })).rejects.toThrow('Expected worker-start') + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + } + ) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index d6b265c41a97..b1f88b0f3991 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -29,6 +29,7 @@ import { tearDownFailedWorkerStart } from './failed-worker-start-teardown' import { requireWorkerAuthority, type WorkerEffect } from './worker-topology' import { prepareLocalWorkerStart } from './worker-start-validation' import { deliverAndSettleWorkerStartReadiness } from './worker-start-readiness-settlement' +import { waitForLaunchedAgentComposer } from '../../../../launched-agent-composer-readiness' type WorkerStartMutation = { callerFingerprint: string @@ -188,17 +189,18 @@ export async function startLocalWorker(args: { effects, timeoutMs: params.timeoutMs ?? 60_000 }) - : // ZCode emits SessionStart only after input; its first dispatch must wait for the composer. - agent === 'zcode' && !params.terminal - ? await runtime.waitForFreshWorkerComposer( + : // A caller-supplied terminal was not freshly launched, so its composer marker may be long gone. + params.terminal || !agent + ? await runtime.waitForTerminal(terminalHandle, { + condition: 'tui-idle', + timeoutMs: params.timeoutMs ?? 60_000 + }) + : await waitForLaunchedAgentComposer( + runtime, terminalHandle, agent, params.timeoutMs ?? 60_000 ) - : await runtime.waitForTerminal(terminalHandle, { - condition: 'tui-idle', - timeoutMs: params.timeoutMs ?? 60_000 - }) if (wait) { persistWorkerSetupWaitOutcome({ ...setupStage, wait }) if (!wait.satisfied) { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-structured-setup-gate.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-structured-setup-gate.ts index d4068ac67769..f302c7942468 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-structured-setup-gate.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-structured-setup-gate.ts @@ -2,8 +2,8 @@ * The `wait-for-setup` gate for a structured worker on a worktree this start created. * * A PTY worker gets the gate for free: agent-first creation sequences the agent's startup command - * behind the setup runner, so `tui-idle` cannot arrive until setup exits, and the worker start - * reads the gate's outcome off that wait. A structured session has no startup command to sequence, + * behind the setup runner, so its readiness wait (`tui-idle`, or its input-box marker) cannot + * arrive until setup exits, and the worker start reads the gate's outcome off that wait. A structured session has no startup command to sequence, * so without this the worker would take its dispatch preamble while `install` was still running, * and the repo's wait-for-setup policy would record no evidence at all. * diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index cda3354e8165..4828d4a505ce 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -391,6 +391,33 @@ describe('orchestration new-worktree workers', () => { ) }) + it.each(['zcode', 'opencode'] as const)( + 'records wait-for-setup success when %s waits on its input box', + async (agent) => { + mockCreatedWorktree({ startupPolicy: 'wait-for-setup', state: 'running' }) + vi.spyOn(runtime, 'getTerminalPtyHostPlatform').mockReturnValue('darwin') + vi.spyOn(runtime, 'waitForFreshWorkerComposer').mockResolvedValue({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + const recordStage = vi.spyOn(db, 'recordWorkerStage') + + const { result } = await startWorker({ agent }) + + expect(runtime.waitForTerminal).not.toHaveBeenCalled() + expect(result).toMatchObject({ + state: 'ready', + setup: { startupPolicy: 'wait-for-setup', state: 'succeeded' } + }) + expect(recordStage).toHaveBeenCalledWith( + expect.objectContaining({ stage: 'setup_settled', setupState: 'succeeded' }) + ) + } + ) + it('does not inject task input when the gated setup terminal fails to start', async () => { mockCreatedWorktree({ startupPolicy: 'wait-for-setup', state: 'spawn_failed' }) vi.mocked(runtime.waitForTerminal).mockResolvedValue({ diff --git a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts deleted file mode 100644 index 30d32c2ede83..000000000000 --- a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' - -describe('ZCode first dispatch readiness', () => { - const h = createOrchestrationWorkerReleaseHarness() - afterEach(() => h.cleanup()) - - it('waits for the new composer before delivering exactly one dispatch', async () => { - h.setup() - const gate = h.deferred() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) - const pending = h.startWorker({ agent: 'zcode' }) - await vi.waitFor(() => - expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( - 'term_worker', - 'zcode', - 60_000 - ) - ) - expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - gate.resolve() - await pending - expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() - }) - - it('keeps reused terminals on the normal idle wait', async () => { - h.setup() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer') - await h.startWorker({ terminal: 'term_worker' }) - expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() - expect(h.runtime.waitForTerminal).toHaveBeenCalledWith( - 'term_worker', - expect.objectContaining({ condition: 'tui-idle' }) - ) - }) - - it('never delivers a task after a startup timeout', async () => { - h.setup() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockRejectedValue(new Error('timeout')) - await expect(h.startWorker({ agent: 'zcode' })).rejects.toThrow('Expected worker-start') - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - }) -}) diff --git a/src/main/runtime/zcode-readiness-transcript.test.ts b/src/main/runtime/zcode-readiness-transcript.test.ts index bd9909f89de7..940e8f38df81 100644 --- a/src/main/runtime/zcode-readiness-transcript.test.ts +++ b/src/main/runtime/zcode-readiness-transcript.test.ts @@ -28,9 +28,14 @@ describe('ZCode readiness from captured terminal bytes', () => { launchAgent: 'zcode', data: '\x1b[?1049h╭' }) - await expect( - runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000) - ).resolves.toBeUndefined() + // The tui-idle result shape, so worker start records a wait-for-setup outcome from it. + await expect(runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000)).resolves.toEqual({ + handle, + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) }) it('never emits an OSC title, so no title lane can settle its wait', () => { diff --git a/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts b/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts index 66511d1fc4c6..75e573bfd87e 100644 --- a/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts +++ b/src/renderer/src/lib/agent-paste-draft-readiness-budget.test.ts @@ -96,8 +96,8 @@ describe('pty-bound agent draft readiness budget', () => { }) it('flags a blind paste when only the opencode process, not its composer, was seen', async () => { - // Regression (#22479): ConPTY never forwards DECSET 2004, so on Windows this is the only - // path opencode can take. Callers must be able to tell it apart from a real delivery. + // Regression (#22479): when the composer signal never fires within the budget, this is the + // path opencode takes. Callers must be able to tell it apart from a real delivery. testState.waitForReady.mockResolvedValue(false) testState.inspectProcess.mockResolvedValue({ foregroundProcess: 'opencode', diff --git a/src/shared/draft-paste-ready-scanner.test.ts b/src/shared/draft-paste-ready-scanner.test.ts index a37111a2e22c..feaa40fa260a 100644 --- a/src/shared/draft-paste-ready-scanner.test.ts +++ b/src/shared/draft-paste-ready-scanner.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createDraftPasteReadyScanner } from './draft-paste-ready-scanner' const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +const DECRST_BRACKETED_PASTE = '\x1b[?2004l' const SHOW_CURSOR = '\x1b[?25h' const HIDE_CURSOR = '\x1b[?25l' const CODEX_PROMPT = '\x1b[1m›\x1b[0m Ask Codex to do anything' @@ -87,6 +88,27 @@ describe('createDraftPasteReadyScanner', () => { }) }) + it('never joins a show-cursor across a chunk seam from bytes it already scanned', () => { + // Why: the stream holds only a hide-cursor; re-reading carried chars used to assemble a show. + const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') + expect(scanner.observe(DECSET_BRACKETED_PASTE).ready).toBe(false) + expect(scanner.observe('Search \x1b[?25').ready).toBe(false) + expect(scanner.observe('l more text').ready).toBe(false) + }) + + it('ignores a show-cursor after the shell turns bracketed paste back off to run a command', () => { + // zsh's prompt enables bracketed paste and disables it on accept-line, before the launcher + // runs; the launcher's cursor toggle stands in for any spinner (synthetic). + const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') + expect(scanner.observe(`${DECSET_BRACKETED_PASTE}% opencode`).ready).toBe(false) + expect(scanner.observe(`${DECRST_BRACKETED_PASTE}\r\n`).ready).toBe(false) + expect(scanner.observe(`${HIDE_CURSOR}resolving${SHOW_CURSOR}`).ready).toBe(false) + expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${SHOW_CURSOR}`)).toEqual({ + ready: true, + armQuietTimer: false + }) + }) + it('ignores show-cursor that appears before bracketed paste is enabled', () => { const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') // A pre-handshake cursor toggle must not trip readiness. @@ -272,17 +294,16 @@ describe('createDraftPasteReadyScanner', () => { }) }) - describe('Windows ConPTY, which never forwards DECSET 2004', () => { - // Why: measured on a remote Windows host (terminal-agent-paste-bracketing.test.ts) — the - // agent's `\x1b[?2004h` is consumed by conhost and never reaches the client stream. Every - // signal below is anchored on it, so on Windows readiness cannot resolve and delivery always - // falls through to the caller's hard timeout and its blind process-ownership paste (#22479). - const WINDOWS_OPENCODE_FRAME = `${HIDE_CURSOR}\x1b[2J\x1b[H opencode ${SHOW_CURSOR}` + describe('a stream that never carries DECSET 2004', () => { + // Why: every signal below is anchored on `\x1b[?2004h`, so without it readiness cannot resolve + // and delivery falls through to the caller's hard timeout. A transport that loses the sequence + // lands here; terminal-agent-paste-bracketing.ts names remote replay and ConPTY as possible. + const ANCHORLESS_OPENCODE_FRAME = `${HIDE_CURSOR}\x1b[2J\x1b[H opencode ${SHOW_CURSOR}` it('never reports opencode ready from show-cursor frames alone', () => { const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') for (let frame = 0; frame < 5; frame += 1) { - expect(scanner.observe(WINDOWS_OPENCODE_FRAME)).toEqual({ + expect(scanner.observe(ANCHORLESS_OPENCODE_FRAME)).toEqual({ ready: false, armQuietTimer: false }) @@ -291,7 +312,7 @@ describe('createDraftPasteReadyScanner', () => { it('never arms the default quiet window either', () => { const scanner = createDraftPasteReadyScanner('render-quiet-after-bracketed-paste') - expect(scanner.observe(WINDOWS_OPENCODE_FRAME)).toEqual({ + expect(scanner.observe(ANCHORLESS_OPENCODE_FRAME)).toEqual({ ready: false, armQuietTimer: false }) diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index f7a9cb7c0c52..c079c724c601 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -4,6 +4,7 @@ import type { DraftPasteReadySignal } from './tui-agent-config' // actually mounted/focused. These markers let the scanner detect the real // "input is ready" moment per agent instead of guessing from output silence. const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +const DECRST_BRACKETED_PASTE = '\x1b[?2004l' const CODEX_COMPOSER_PROMPT = '›' // Why: opencode emits the DECTCEM show-cursor only once the composer row is // mounted and the text cursor is placed in it — a "composer ready" signal, @@ -48,7 +49,10 @@ const DRAFT_PASTE_READY_SIGNALS: Record { + const scanRevocableAnchorSegments = ( + window: string, + carriedLength: number, + anchor: string, + end: string + ): boolean => { let cursor = 0 while (cursor < window.length) { if (!sawMarkerAnchor) { @@ -191,7 +201,11 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) continue } const leaveIndex = window.indexOf(end, cursor) - const segment = leaveIndex === -1 ? window.slice(cursor) : window.slice(cursor, leaveIndex) + // Why: carried chars only rejoin a split anchor or leave; postAnchorRecent already holds + // them, so re-reading them could join a marker across the duplicated seam. + const segmentStart = Math.max(cursor, carriedLength) + const segment = + leaveIndex === -1 ? window.slice(segmentStart) : window.slice(segmentStart, leaveIndex) if ((postAnchorRecent + segment).includes(signalMarker ?? '')) { return true } @@ -251,9 +265,10 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) if (markerAnchorEnd !== null) { // Why: carry only the bytes an anchor could straddle, so already-scanned // output is never re-walked into a second enter/leave transition. + const carriedLength = anchorCarry.length const window = anchorCarry + data anchorCarry = window.slice(-ANCHOR_CARRY_CHARS) - if (scanRevocableAnchorSegments(window, markerAnchor, markerAnchorEnd)) { + if (scanRevocableAnchorSegments(window, carriedLength, markerAnchor, markerAnchorEnd)) { return { ready: true, armQuietTimer: false } } } else if (!sawMarkerAnchor) { diff --git a/src/shared/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts index aa393fd522b7..2c0f3feaf798 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -43,6 +43,11 @@ export type TuiAgentConfig = { draftPasteReadySignal?: DraftPasteReadySignal /** Hard deadline for the agent's composer readiness signal. */ draftPasteReadyTimeoutMs?: number + /** + * Committed `src/main/runtime/__fixtures__` captures in which `draftPasteReadySignal` fires at + * the input box. Present = a freshly launched worker waits for that marker alone before its task. + */ + composerReadyCaptures?: readonly string[] /** Delay before one extra blind submit Enter, for agents that render their composer before Enter is live (codex); a no-op if the first Enter landed. */ submitRetryDelayMs?: number /** Extra ms per logical prompt line before Enter, for TUIs that expand multiline paste slowly (antigravity). */ diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index d9fdabb14c34..70e54675fff4 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -98,7 +98,12 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { // composer is not ready until ~10s — so the 8s default expired first and the draft // was pasted blind, mid-startup (#22479). The signal itself fired every time in // those runs, so the budget was the problem, not a dropped escape. - draftPasteReadyTimeoutMs: 20_000 + draftPasteReadyTimeoutMs: 20_000, + composerReadyCaptures: [ + 'opencode-1-18-32-timed-boot-slow', + 'opencode-1-18-32-timed-boot-hidden-pane', + 'opencode-1-18-32-timed-first-launch' + ] }, // Why: opencode2 installs as a separate binary and uses the same prompt flags. // Its @opentui composer keeps the same cursor-gated paste signal. @@ -108,7 +113,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { launchCmd: 'opencode2 --standalone', promptInjectionMode: 'flag-prompt', draftPasteReadySignal: 'render-cursor-after-bracketed-paste', - draftPasteReadyTimeoutMs: 20_000 + draftPasteReadyTimeoutMs: 20_000, + composerReadyCaptures: ['opencode-2-0-18-timed-boot-hidden-pane'] }, 'mimo-code': { detectCmd: 'mimo', @@ -318,7 +324,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { promptInjectionMode: 'stdin-after-start', // Why: ZCode repaints an animated ASCII banner indefinitely, so the default quiet-render // window never settles; its composer box corner is the real "input is live" signal. - draftPasteReadySignal: 'zcode-composer-prompt' + draftPasteReadySignal: 'zcode-composer-prompt', + composerReadyCaptures: ['zcode-composer-ready'] }, devin: { detectCmd: 'devin', From af2dc3eb73196780cb10bd033c9a9fd649d6f10f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:01:57 -0700 Subject: [PATCH 2/9] fix(orchestration): a worker started on a paired host also waits for its input box `worker-start --on ` launches the agent through the federated attach on the worker host, which still waited tui-idle for every agent. An OpenCode worker there could get its task typed before its input box existed, the same loss the local start no longer has. The attach now takes the same wait as a local start: a terminal it created waits for the composer marker when the agent's row cites captures of it (OpenCode, OpenCode 2, ZCode) and the pane is not Windows-hosted; a caller-supplied --terminal keeps tui-idle. Both paths share one helper so the fresh-vs-reused rule lives in one place. Also drops the retracted "ConPTY does not forward DECSET 2004" claim from the blind-paste comment in agent-paste-draft.ts. --- .../launched-agent-composer-readiness.ts | 12 ++ ...ederation-launched-agent-readiness.test.ts | 133 ++++++++++++++++++ .../orchestration/federation/federation.ts | 6 +- .../worker/local-worker-start.ts | 19 +-- src/renderer/src/lib/agent-paste-draft.ts | 5 +- 5 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts diff --git a/src/main/runtime/launched-agent-composer-readiness.ts b/src/main/runtime/launched-agent-composer-readiness.ts index 7a36c00e47e6..c1d3dc5a05d9 100644 --- a/src/main/runtime/launched-agent-composer-readiness.ts +++ b/src/main/runtime/launched-agent-composer-readiness.ts @@ -47,3 +47,15 @@ export function waitForLaunchedAgentComposer( ? runtime.waitForFreshWorkerComposer(handle, agent, timeoutMs) : runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs }) } + +/** Worker start's wait before its first dispatch, on the local host and on a paired worker host. */ +export function waitForWorkerAgentReady( + runtime: LaunchedAgentReadinessRuntime, + handle: string, + args: { agent: TuiAgent | undefined; reusesTerminal: boolean; timeoutMs: number } +): Promise { + // A caller-supplied terminal was not freshly launched, so its composer marker may be long gone. + return args.agent && !args.reusesTerminal + ? waitForLaunchedAgentComposer(runtime, handle, args.agent, args.timeoutMs) + : runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: args.timeoutMs }) +} diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts new file mode 100644 index 000000000000..3c8ad54be387 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveWorkerStartReadinessTimeoutMs } from '../../../../../../shared/orchestration-timing-budgets' +import { OrcaRuntimeService } from '../../../../orca-runtime' +import { OrchestrationDb } from '../../../../orchestration/db' +import { ORCHESTRATION_METHODS } from '../../orchestration' +import { configureFederationWorkerRuntime } from './federation-runtime.test-support' + +const HANDLE = 'term_windows_worker' +const WORKTREE = 'repo::windows-worktree' + +// Why: `worker-start --on ` launches the agent through this attach on the worker host, so +// it must wait for the same input box a local start waits for, or the task is typed too early. +describe('federated worker first dispatch readiness', () => { + const databases: OrchestrationDb[] = [] + + afterEach(() => { + for (const db of databases.splice(0)) { + db.close() + } + vi.restoreAllMocks() + }) + + function createWorkerHost(ptyHostPlatform: NodeJS.Platform): OrcaRuntimeService { + const db = new OrchestrationDb(':memory:') + databases.push(db) + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + configureFederationWorkerRuntime(runtime) + vi.spyOn(runtime, 'getTerminalPtyHostPlatform').mockReturnValue(ptyHostPlatform) + vi.spyOn(runtime, 'waitForFreshWorkerComposer').mockResolvedValue({ + handle: HANDLE, + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + return runtime + } + + async function attach( + runtime: OrcaRuntimeService, + target: { agent: string } | { terminal: string } + ): Promise { + const method = ORCHESTRATION_METHODS.find( + (candidate) => candidate.name === 'orchestration.federationAttachStart' + ) + if (!method) { + throw new Error('federationAttachStart method is not registered') + } + const placement = + 'agent' in target + ? { worktree: 'new-top-level', repo: 'windows-repo', name: 'remote-worker', ...target } + : { worktree: WORKTREE, ...target } + return await method.handler( + method.params!.parse({ + runId: 'run-home', + dispatchId: 'ctx_remote', + taskId: 'task_remote', + taskSpec: 'remote worker', + protocolVersion: 3, + ...placement + }), + { + runtime, + orchestrationMutation: { + callerFingerprint: 'home_peer', + requestId: 'request_remote', + method: 'orchestration.federationAttachStart', + payloadHash: 'remote_payload' + } + } + ) + } + + it.each(['opencode', 'opencode2', 'zcode'] as const)( + 'a %s terminal this attach created waits for its input box, not tui-idle', + async (agent) => { + const runtime = createWorkerHost('darwin') + + await expect(attach(runtime, { agent })).resolves.toMatchObject({ state: 'ready' }) + + expect(runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( + HANDLE, + agent, + resolveWorkerStartReadinessTimeoutMs(undefined) + ) + expect(runtime.waitForTerminal).not.toHaveBeenCalled() + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + } + ) + + it('a reused --terminal keeps the tui-idle wait', async () => { + const runtime = createWorkerHost('darwin') + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reuse path reads only the worktree id. + { id: WORKTREE } as never + ) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + + await expect(attach(runtime, { terminal: HANDLE })).resolves.toMatchObject({ state: 'ready' }) + + expect(runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() + expect(runtime.waitForTerminal).toHaveBeenCalledWith( + HANDLE, + expect.objectContaining({ condition: 'tui-idle' }) + ) + }) + + it('an opencode pane on a Windows pty host keeps the tui-idle wait', async () => { + const runtime = createWorkerHost('win32') + + await expect(attach(runtime, { agent: 'opencode' })).resolves.toMatchObject({ state: 'ready' }) + + expect(runtime.getTerminalPtyHostPlatform).toHaveBeenCalledWith(HANDLE) + expect(runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() + expect(runtime.waitForTerminal).toHaveBeenCalledWith( + HANDLE, + expect.objectContaining({ condition: 'tui-idle' }) + ) + }) + + it('fails at agent_readiness without typing the task when the input box never appears', async () => { + const runtime = createWorkerHost('darwin') + vi.mocked(runtime.waitForFreshWorkerComposer).mockRejectedValue(new Error('timeout')) + + await expect(attach(runtime, { agent: 'opencode' })).resolves.toMatchObject({ + state: 'failed', + failedStage: 'agent_readiness', + lastError: 'timeout' + }) + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index 067f583e37b4..694036ea677e 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -27,6 +27,7 @@ import { resolveWorkerStartReadinessTimeoutMs } from '../../../../../../shared/orchestration-timing-budgets' import { assertWorkerStartTaskSpecWithinPromptBudget } from '../worker/worker-start-prompt-budget' +import { waitForWorkerAgentReady } from '../../../../launched-agent-composer-readiness' export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ defineMethod({ @@ -217,8 +218,9 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ } persistFederatedReadinessStage(setupStage) failedStage = 'agent_readiness' - const wait = await runtime.waitForTerminal(terminalHandle, { - condition: 'tui-idle', + const wait = await waitForWorkerAgentReady(runtime, terminalHandle, { + agent, + reusesTerminal: Boolean(params.terminal), timeoutMs: readinessTimeoutMs }) persistFederatedSetupWaitOutcome({ ...setupStage, wait }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index b1f88b0f3991..1d8b93562551 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -29,7 +29,7 @@ import { tearDownFailedWorkerStart } from './failed-worker-start-teardown' import { requireWorkerAuthority, type WorkerEffect } from './worker-topology' import { prepareLocalWorkerStart } from './worker-start-validation' import { deliverAndSettleWorkerStartReadiness } from './worker-start-readiness-settlement' -import { waitForLaunchedAgentComposer } from '../../../../launched-agent-composer-readiness' +import { waitForWorkerAgentReady } from '../../../../launched-agent-composer-readiness' type WorkerStartMutation = { callerFingerprint: string @@ -189,18 +189,11 @@ export async function startLocalWorker(args: { effects, timeoutMs: params.timeoutMs ?? 60_000 }) - : // A caller-supplied terminal was not freshly launched, so its composer marker may be long gone. - params.terminal || !agent - ? await runtime.waitForTerminal(terminalHandle, { - condition: 'tui-idle', - timeoutMs: params.timeoutMs ?? 60_000 - }) - : await waitForLaunchedAgentComposer( - runtime, - terminalHandle, - agent, - params.timeoutMs ?? 60_000 - ) + : await waitForWorkerAgentReady(runtime, terminalHandle, { + agent, + reusesTerminal: Boolean(params.terminal), + timeoutMs: params.timeoutMs ?? 60_000 + }) if (wait) { persistWorkerSetupWaitOutcome({ ...setupStage, wait }) if (!wait.satisfied) { diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index feca855338a2..6bc9b76404fa 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -126,9 +126,8 @@ export async function pasteDraftWhenAgentReady(args: { onTimeout?.() return false } - // Why: the process merely exists -- its composer was never observed. On Windows this is - // the ONLY path: ConPTY does not forward DECSET 2004, so no 2004-anchored ready signal - // can ever fire. Callers must be able to tell this blind write apart from a real delivery. + // Why: the process merely exists -- its composer signal never fired within the budget. + // Callers must be able to tell this blind write apart from a real delivery. onUnconfirmedDelivery?.() } From cd6286109c5aaa9ea8eebf7906aeca3a50628547 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:06:05 -0700 Subject: [PATCH 3/9] fix(orchestration): OpenCode workers on Windows panes wait for the input box too A capture through Orca's bundled conpty.dll (Windows 11, PowerShell launched as Orca launches it, OpenCode 1.18.32 and 2.0.18, 8 of 8 runs) shows OpenCode's bracketed-paste enable always arriving before its input box, no disable while booting, and the composer marker firing on exactly the read that first draws the box. The temporary Windows host check that kept these panes on tui-idle is therefore removed: the lane comes from the row's evidence alone, and the pty-host-platform lookup added only for that check goes with it. --- .../launched-agent-composer-readiness.test.ts | 22 +----------------- .../launched-agent-composer-readiness.ts | 23 ++++--------------- ...pencode-worker-composer-transcript.test.ts | 1 - .../orca-runtime-activate-managed-worktree.ts | 14 ++--------- ...ederation-launched-agent-readiness.test.ts | 22 ++++-------------- .../launched-agent-worker-readiness.test.ts | 18 --------------- .../worker/workers-new-worktree.test.ts | 1 - 7 files changed, 11 insertions(+), 90 deletions(-) diff --git a/src/main/runtime/launched-agent-composer-readiness.test.ts b/src/main/runtime/launched-agent-composer-readiness.test.ts index 0b5589ceb9f1..7f8e7a18fa15 100644 --- a/src/main/runtime/launched-agent-composer-readiness.test.ts +++ b/src/main/runtime/launched-agent-composer-readiness.test.ts @@ -111,27 +111,7 @@ describe('which lane a freshly launched worker waits on', () => { if (!isTuiAgent(agent)) { throw new Error(`${agent} is not a TuiAgent`) } - expect(getLaunchedAgentReadinessLane(agent, () => 'darwin')).toBe(lane) - expect(getLaunchedAgentReadinessLane(agent, () => 'linux')).toBe(lane) - }) - - it.each(['opencode', 'opencode2'] as const)( - '%s on a Windows-hosted pane keeps tui-idle until a ConPTY capture exists (temporary)', - (agent) => { - expect(getLaunchedAgentReadinessLane(agent, () => 'win32')).toBe('tui-idle') - } - ) - - it('keeps ZCode on its marker on Windows, whose marker is anchored on the alternate screen', () => { - expect(getLaunchedAgentReadinessLane('zcode', () => 'win32')).toBe('composer-marker') - }) - - it('asks for the pane host only for rows that carry evidence', () => { - expect( - getLaunchedAgentReadinessLane('claude', () => { - throw new Error('the host must not be asked') - }) - ).toBe('tui-idle') + expect(getLaunchedAgentReadinessLane(agent)).toBe(lane) }) }) diff --git a/src/main/runtime/launched-agent-composer-readiness.ts b/src/main/runtime/launched-agent-composer-readiness.ts index c1d3dc5a05d9..060e07498be9 100644 --- a/src/main/runtime/launched-agent-composer-readiness.ts +++ b/src/main/runtime/launched-agent-composer-readiness.ts @@ -15,25 +15,11 @@ export type LaunchedAgentReadinessLane = 'composer-marker' | 'tui-idle' export type LaunchedAgentReadinessRuntime = Pick< OrcaRuntimeService, - 'waitForTerminal' | 'waitForFreshWorkerComposer' | 'getTerminalPtyHostPlatform' + 'waitForTerminal' | 'waitForFreshWorkerComposer' > -export function getLaunchedAgentReadinessLane( - agent: TuiAgent, - getPtyHostPlatform: () => NodeJS.Platform -): LaunchedAgentReadinessLane { - const row = TUI_AGENT_CONFIG[agent] - if (!row.composerReadyCaptures?.length) { - return 'tui-idle' - } - // Temporary: no capture through Orca's bundled conpty.dll shows this marker at the box yet. - if ( - row.draftPasteReadySignal === 'render-cursor-after-bracketed-paste' && - getPtyHostPlatform() === 'win32' - ) { - return 'tui-idle' - } - return 'composer-marker' +export function getLaunchedAgentReadinessLane(agent: TuiAgent): LaunchedAgentReadinessLane { + return TUI_AGENT_CONFIG[agent].composerReadyCaptures?.length ? 'composer-marker' : 'tui-idle' } export function waitForLaunchedAgentComposer( @@ -42,8 +28,7 @@ export function waitForLaunchedAgentComposer( agent: TuiAgent, timeoutMs: number ): Promise { - return getLaunchedAgentReadinessLane(agent, () => runtime.getTerminalPtyHostPlatform(handle)) === - 'composer-marker' + return getLaunchedAgentReadinessLane(agent) === 'composer-marker' ? runtime.waitForFreshWorkerComposer(handle, agent, timeoutMs) : runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs }) } diff --git a/src/main/runtime/opencode-worker-composer-transcript.test.ts b/src/main/runtime/opencode-worker-composer-transcript.test.ts index f3f3f71745af..52aa2f4da86e 100644 --- a/src/main/runtime/opencode-worker-composer-transcript.test.ts +++ b/src/main/runtime/opencode-worker-composer-transcript.test.ts @@ -50,7 +50,6 @@ async function replay(name: string, agent: TuiAgent) { size: { cols: 120, rows: 40 }, data: '' }) - vi.spyOn(runtime, 'getTerminalPtyHostPlatform').mockReturnValue('darwin') vi.useFakeTimers() runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ZSH_LAUNCH, Date.now()) const settledAt: { composer: number | null; tuiIdle: number | null } = { diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 587ccd6217dd..71d97dedbf68 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -178,18 +178,14 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM pasteWorktreeStartupDraftWhenReady(this.getWorktreeStartupReadinessHost(), handle, draft) } - /** Platform of the pty transport behind a handle: Windows for a local WSL pane, the remote OS over SSH. */ - getTerminalPtyHostPlatform(handle: string): NodeJS.Platform { - return this.getPtyWriteHostPlatform(this.getLiveTerminalPtyId(handle)) - } - /** Only for a newly launched worker, before its first dispatch input. */ async waitForFreshWorkerComposer( handle: string, agent: TuiAgent, timeoutMs: number ): Promise { - const initialPtyId = this.getLiveTerminalPtyId(handle) + const initialPtyId = + this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId const ptyId = await waitForWorktreeStartupDraft( { ...this.getWorktreeStartupReadinessHost(), getPtyId: () => initialPtyId }, handle, @@ -207,12 +203,6 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM return { handle, condition: 'tui-idle', satisfied: true, status: 'running', exitCode: null } } - private getLiveTerminalPtyId(handle: string): string { - return ( - this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId - ) - } - protected sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void { sendWorktreeStartupFollowupWhenReady(this.getWorktreeStartupReadinessHost(), handle, followup) } diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts index 3c8ad54be387..55379b33ff02 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts @@ -20,13 +20,12 @@ describe('federated worker first dispatch readiness', () => { vi.restoreAllMocks() }) - function createWorkerHost(ptyHostPlatform: NodeJS.Platform): OrcaRuntimeService { + function createWorkerHost(): OrcaRuntimeService { const db = new OrchestrationDb(':memory:') databases.push(db) const runtime = new OrcaRuntimeService() runtime.setOrchestrationDb(db) configureFederationWorkerRuntime(runtime) - vi.spyOn(runtime, 'getTerminalPtyHostPlatform').mockReturnValue(ptyHostPlatform) vi.spyOn(runtime, 'waitForFreshWorkerComposer').mockResolvedValue({ handle: HANDLE, condition: 'tui-idle', @@ -75,7 +74,7 @@ describe('federated worker first dispatch readiness', () => { it.each(['opencode', 'opencode2', 'zcode'] as const)( 'a %s terminal this attach created waits for its input box, not tui-idle', async (agent) => { - const runtime = createWorkerHost('darwin') + const runtime = createWorkerHost() await expect(attach(runtime, { agent })).resolves.toMatchObject({ state: 'ready' }) @@ -90,7 +89,7 @@ describe('federated worker first dispatch readiness', () => { ) it('a reused --terminal keeps the tui-idle wait', async () => { - const runtime = createWorkerHost('darwin') + const runtime = createWorkerHost() vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue( // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reuse path reads only the worktree id. { id: WORKTREE } as never @@ -106,21 +105,8 @@ describe('federated worker first dispatch readiness', () => { ) }) - it('an opencode pane on a Windows pty host keeps the tui-idle wait', async () => { - const runtime = createWorkerHost('win32') - - await expect(attach(runtime, { agent: 'opencode' })).resolves.toMatchObject({ state: 'ready' }) - - expect(runtime.getTerminalPtyHostPlatform).toHaveBeenCalledWith(HANDLE) - expect(runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() - expect(runtime.waitForTerminal).toHaveBeenCalledWith( - HANDLE, - expect.objectContaining({ condition: 'tui-idle' }) - ) - }) - it('fails at agent_readiness without typing the task when the input box never appears', async () => { - const runtime = createWorkerHost('darwin') + const runtime = createWorkerHost() vi.mocked(runtime.waitForFreshWorkerComposer).mockRejectedValue(new Error('timeout')) await expect(attach(runtime, { agent: 'opencode' })).resolves.toMatchObject({ diff --git a/src/main/runtime/rpc/methods/orchestration/worker/launched-agent-worker-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/launched-agent-worker-readiness.test.ts index 7b8904e7c0dc..92ccc609b7d0 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/launched-agent-worker-readiness.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/launched-agent-worker-readiness.test.ts @@ -18,7 +18,6 @@ describe('first dispatch readiness of a freshly launched worker', () => { '%s waits for its input box before delivering exactly one dispatch', async (agent) => { h.setup() - vi.spyOn(h.runtime, 'getTerminalPtyHostPlatform').mockReturnValue('darwin') const gate = h.deferred() vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) const pending = h.startWorker({ agent }) @@ -51,22 +50,6 @@ describe('first dispatch readiness of a freshly launched worker', () => { } ) - it.each(['opencode', 'opencode2'] as const)( - '%s on a Windows-hosted pane keeps the tui-idle wait until a ConPTY capture exists', - async (agent) => { - h.setup() - vi.spyOn(h.runtime, 'getTerminalPtyHostPlatform').mockReturnValue('win32') - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer') - await h.startWorker({ agent }) - expect(h.runtime.getTerminalPtyHostPlatform).toHaveBeenCalledWith('term_worker') - expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() - expect(h.runtime.waitForTerminal).toHaveBeenCalledWith( - 'term_worker', - expect.objectContaining({ condition: 'tui-idle' }) - ) - } - ) - it('keeps reused terminals on the normal idle wait', async () => { h.setup() vi.spyOn(h.runtime, 'waitForFreshWorkerComposer') @@ -82,7 +65,6 @@ describe('first dispatch readiness of a freshly launched worker', () => { '%s never delivers a task after a startup timeout', async (agent) => { h.setup() - vi.spyOn(h.runtime, 'getTerminalPtyHostPlatform').mockReturnValue('darwin') vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockRejectedValue(new Error('timeout')) await expect(h.startWorker({ agent })).rejects.toThrow('Expected worker-start') expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index 4828d4a505ce..c86f340951b5 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -395,7 +395,6 @@ describe('orchestration new-worktree workers', () => { 'records wait-for-setup success when %s waits on its input box', async (agent) => { mockCreatedWorktree({ startupPolicy: 'wait-for-setup', state: 'running' }) - vi.spyOn(runtime, 'getTerminalPtyHostPlatform').mockReturnValue('darwin') vi.spyOn(runtime, 'waitForFreshWorkerComposer').mockResolvedValue({ handle: 'term_worker', condition: 'tui-idle', From 5ff7d779449c6f97aedce9f3092bad2583eb7c23 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:26:15 -0700 Subject: [PATCH 4/9] test(daemon): record the serialize-replay baseline for the new OpenCode boot captures The transcript replay suite reads every capture under runtime/__fixtures__, so the four OpenCode boot recordings this branch adds are replayed through the serialize round trip too. Each shows the round trip's known grid divergences after a resize (16, 16, 16 and 14 checks), and no I1 byte difference or I3 regression. The serializer and this suite are unchanged from main, so these are pre-existing limitations surfaced by new inputs; record their counts like the other captures' baselines. --- src/main/daemon/serialize-grid-transcript-replay.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index e404ccb04e2c..975f3bc9e969 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -35,6 +35,12 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record = { less: 6, nano: 2, opencode: 5, + // OpenCode boot captures: rows on its true-colour background, and the row a shrink leaves + // behind, restore inexactly after a resize. Pre-existing: they landed with no serializer change. + 'opencode-1-18-32-timed-boot-hidden-pane': 16, + 'opencode-1-18-32-timed-boot-slow': 16, + 'opencode-1-18-32-timed-first-launch': 16, + 'opencode-2-0-18-timed-boot-hidden-pane': 14, // Shrink leaves the cursor one column short; also present in the pre-Qoder serializer. 'qoder-no-account': 2, 'qoder-ready': 2, From 245572e3936af46bce260646df9174bc50989b46 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:44:55 -0700 Subject: [PATCH 5/9] fix(orchestration): a launched OpenCode worker's first dispatch gets one retry Enter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode 2 drops an Enter that arrives before it has drawn its mode row (`Build · `). Worker start pastes at the input-box marker and sends Enter about 0.5 s later, so on a loaded machine the brief could stay in the box as `[Pasted ~N lines]` while the receipt said accepted. Measured: an Enter after the mode row submitted 52/52; one before it stuck 14/18; a second Enter 2 s later submitted every stuck brief (7/7); a lone extra Enter after a landed submit, even mid-reply, changed nothing (60/60 across 2.0.18 and 1.18.32). The agent row's existing one-retry setting, `submitRetryDelayMs`, is set to 2000 ms for opencode and opencode2, and both paths now read it: - worker start (local, and `--on` when the attach launched the terminal) sends one extra Enter after that delay, under the same per-pane serialisation and generation/permission checks as the first; a refused retry leaves the first Enter's result standing. Render-gated agents (claude, codex) are excluded. Coordinator dispatch, re-dispatch, `terminal send` and reused terminals are unchanged. - the renderer's submit-after-ready paste already sent the row's retry Enter (codex); it now covers OpenCode too, which it pastes at the same marker with a 50 ms Enter gap. The paired-host attach now delivers its preamble through the same helper as a local start instead of a copy of it. --- .../agent-prompt-launch-submit-retry.test.ts | 106 ++++++++++++++++++ ...rca-runtime-write-terminal-agent-prompt.ts | 36 +++++- ...ederation-launched-agent-readiness.test.ts | 10 ++ .../orchestration/federation/federation.ts | 37 +++--- .../deliver-worker-dispatch-preamble.ts | 11 +- .../launched-agent-worker-readiness.test.ts | 10 ++ .../worker/local-worker-start.ts | 1 + .../worker-start-readiness-settlement.ts | 4 +- .../runtime/runtime-terminal-contracts.ts | 2 + .../agent-paste-draft-submit-retry.test.ts | 23 ++++ src/renderer/src/lib/agent-paste-draft.ts | 3 +- src/shared/agent-prompt-injection.ts | 5 + src/shared/tui-agent-config-types.ts | 2 +- src/shared/tui-agent-config.ts | 9 +- 14 files changed, 222 insertions(+), 37 deletions(-) create mode 100644 src/main/runtime/agent-prompt-launch-submit-retry.test.ts diff --git a/src/main/runtime/agent-prompt-launch-submit-retry.test.ts b/src/main/runtime/agent-prompt-launch-submit-retry.test.ts new file mode 100644 index 000000000000..b744828c1a64 --- /dev/null +++ b/src/main/runtime/agent-prompt-launch-submit-retry.test.ts @@ -0,0 +1,106 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt-injection' +import type { TuiAgent } from '../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { createAgentPromptSubmissionRuntime } from './agent-prompt-submission-runtime-test-fixture' +import { dispatchPreambleSendOptions } from './orchestration/preamble' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/launch-submit-retry', + isBare: false, + isMainWorktree: false + } + ]), + listWorktreesStrict: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/launch-submit-retry', + isBare: false, + isMainWorktree: false + } + ]) +})) + +const PROMPT = 'Task: reply with OK' + +async function sendDispatch(agent: TuiAgent, retrySubmitAfterLaunch: boolean) { + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime(() => {}, agent) + const sent = runtime.sendTerminalAgentPrompt(handle, PROMPT, { + ...dispatchPreambleSendOptions('request-1'), + retrySubmitAfterLaunch + }) + return { runtime, sent, enters: () => writes.filter((data) => data === '\r').length } +} + +// Why: OpenCode 2 drops an Enter sent before its mode row is drawn, so a worker's first dispatch +// gets one more Enter; every other send keeps exactly one. +describe('retry Enter on a launched worker first dispatch', () => { + afterEach(() => vi.useRealTimers()) + + it.each(['opencode', 'opencode2'] as const)( + '%s gets a second Enter after its row delay', + async (agent) => { + vi.useFakeTimers() + const submitDelayMs = resolveAgentPromptSubmitDelayForAgent(process.platform, PROMPT, agent) + const retryDelayMs = TUI_AGENT_CONFIG[agent].submitRetryDelayMs ?? 0 + const { sent, enters } = await sendDispatch(agent, true) + + await vi.advanceTimersByTimeAsync(submitDelayMs) + expect(enters()).toBe(1) + await vi.advanceTimersByTimeAsync(retryDelayMs - 1) + expect(enters()).toBe(1) + await vi.advanceTimersByTimeAsync(1) + + await expect(sent).resolves.toMatchObject({ + accepted: true, + bytesWritten: expect.any(Number), + prompt: { stages: ['input_accepted'] } + }) + expect(enters()).toBe(2) + } + ) + + it('keeps one Enter for dispatches that did not launch the agent', async () => { + vi.useFakeTimers() + const { sent, enters } = await sendDispatch('opencode', false) + + await vi.runAllTimersAsync() + await sent + expect(enters()).toBe(1) + }) + + it.each(['codex', 'claude', 'aider'] as const)( + '%s keeps one Enter on a launched worker first dispatch', + async (agent) => { + vi.useFakeTimers() + const { sent, enters } = await sendDispatch(agent, true) + + await vi.runAllTimersAsync() + await sent + expect(enters()).toBe(1) + } + ) + + it('leaves the first Enter standing when the pane exits before the retry', async () => { + vi.useFakeTimers() + const submitDelayMs = resolveAgentPromptSubmitDelayForAgent( + process.platform, + PROMPT, + 'opencode' + ) + const { runtime, sent, enters } = await sendDispatch('opencode', true) + + await vi.advanceTimersByTimeAsync(submitDelayMs) + expect(enters()).toBe(1) + await runtime.onPtyExit('pty-prompt', 0) + await vi.runAllTimersAsync() + + await expect(sent).resolves.toMatchObject({ accepted: true }) + expect(enters()).toBe(1) + }) +}) diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index 1597084eee15..7b6960356dff 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -11,6 +11,7 @@ import { AGENT_PROMPT_SUBMIT, agentPromptSubmitJoinsPasteFrame, getAgentPromptSubmitDelayMs, + getAgentSubmitRetryDelayMs, getTerminalPasteIngestMs, resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt-injection' @@ -44,6 +45,11 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso const pasteByteLength = Buffer.byteLength(pastePayload, 'utf8') const pasteIngestMs = getTerminalPasteIngestMs(writeHostPlatform, pasteByteLength) const renderGate = this.createAgentPromptRenderGate(ptyId, pasteIngestMs) + // Render-gated agents (claude, codex) already wait for their composer to draw the paste. + const submitRetryDelayMs = + options.retrySubmitAfterLaunch && !submitWithPaste && !renderGate + ? getAgentSubmitRetryDelayMs(this.getPtyAgent(ptyId)) + : undefined const waitTextCache: AgentPromptWaitTextCache = {} const preSubmitBaseline = submitWithPaste ? this.getAgentPromptActivity(handle, ptyId, waitTextCache) @@ -107,6 +113,24 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } } + let submits = 1 + if (submitRetryDelayMs !== undefined) { + try { + await waitForAgentPromptDelay(submitRetryDelayMs, options.signal) + assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) + // A permission prompt drawn after the first Enter must never be answered by the retry. + this.assertAgentPromptPermissionSafe( + permissionBaseline, + this.getAgentPromptActivity(handle, ptyId) + ) + if (this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, options.inputKind)) { + submits = 2 + } + } catch { + // The retry is best-effort; a refused one leaves the first Enter's result standing. + } + } const effectTimeoutMs = resolveAgentPromptEffectTimeoutMs(this.getPtyAgent(ptyId)) if (!options.acceptQueued || !options.requestId) { await verifyAgentPromptSubmission({ @@ -115,7 +139,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso timeoutMs: effectTimeoutMs, signal: options.signal }) - return { submits: 1 } + return { submits } } const binding = this.getTerminalPromptRequestBinding(handle) const foregroundAgent = this.ptysById.get(ptyId)?.foregroundAgent @@ -139,7 +163,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso const checkpoint: RuntimeTerminalSend = { handle, accepted: true, - bytesWritten: Buffer.byteLength(pastePayload, 'utf8') + 1, + bytesWritten: Buffer.byteLength(pastePayload, 'utf8') + submits, prompt: inputAccepted } options.onInputAccepted?.(checkpoint) @@ -147,7 +171,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso // receipt; they must not fail a Dispatch merely because Orca cannot prove // submission through hooks. if (!settlementAgent) { - return { submits: 1, prompt: inputAccepted } + return { submits, prompt: inputAccepted } } this.registerAgentPromptRequest( ptyId, @@ -175,7 +199,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso }) this.forgetAgentPromptRequest(ptyId, generation, options.requestId) return { - submits: 1, + submits, prompt: { ...inputAccepted, stages: ['input_accepted', 'turn_started'] @@ -183,12 +207,12 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso } } catch (error) { if (error instanceof Error && error.message === 'agent_prompt_stalled') { - return { submits: 1, prompt: inputAccepted } + return { submits, prompt: inputAccepted } } if (error instanceof Error && error.message === 'agent_prompt_blocked') { this.forgetAgentPromptRequest(ptyId, generation, options.requestId) return { - submits: 1, + submits, prompt: { ...inputAccepted, observation: 'permission' } } } diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts index 55379b33ff02..409c508b7f4c 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts @@ -85,6 +85,11 @@ describe('federated worker first dispatch readiness', () => { ) expect(runtime.waitForTerminal).not.toHaveBeenCalled() expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + HANDLE, + expect.any(String), + expect.objectContaining({ retrySubmitAfterLaunch: true }) + ) } ) @@ -103,6 +108,11 @@ describe('federated worker first dispatch readiness', () => { HANDLE, expect.objectContaining({ condition: 'tui-idle' }) ) + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + HANDLE, + expect.any(String), + expect.objectContaining({ retrySubmitAfterLaunch: false }) + ) }) it('fails at agent_readiness without typing the task when the input box never appears', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index 694036ea677e..437a34dbe156 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -1,9 +1,6 @@ import type { TuiAgent } from '../../../../../../shared/tui-agent' import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' -import { - buildDispatchPreamble, - dispatchPreambleSendOptions -} from '../../../../orchestration/preamble' +import { deliverWorkerDispatchPreamble } from '../worker/deliver-worker-dispatch-preamble' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { defineMethod } from '../../../core' import { assertOrchestrationWorktreeCreationSupported } from '../worker/folder-worktree-placement' @@ -253,23 +250,21 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ terminalOwnership: params.terminal ? 'external' : 'created' }) failedStage = 'dispatch_input' - const prompt = await runtime.sendTerminalAgentPrompt( + // Nesting is checked against this worker host's own cap: enforcement runs here. + const delivery = await deliverWorkerDispatchPreamble({ + runtime, + structuredSession: null, terminalHandle, - buildDispatchPreamble({ - taskId: params.taskId, - dispatchId: params.dispatchId, - taskSpec: params.taskSpec, - coordinatorHandle: 'Run home (relayed by Orca)', - workerHandle: terminalHandle, - dispatchCapability: capability, - devMode: params.devMode, - // Why the worker host's own setting: enforcement runs here, with this - // host's code, against this host's cap. - canDispatchSubWorkers: (params.depth ?? 1) < runtime.getNestedWorkerMaxDepth(), - cliCommand: runtime.getTerminalOrchestrationCliCommand(terminalHandle) - }), - dispatchPreambleSendOptions(orchestrationMutation.requestId) - ) + dispatchId: params.dispatchId, + dispatchDepth: params.depth ?? 1, + taskId: params.taskId, + taskSpec: params.taskSpec, + coordinatorHandle: 'Run home (relayed by Orca)', + dispatchCapability: capability, + devMode: params.devMode, + requestId: orchestrationMutation.requestId, + launchedTerminal: !params.terminal + }) effects.push({ kind: 'dispatch_input', role: 'agent', @@ -288,7 +283,7 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ setup, launch: launch.receipt, effects, - ...(prompt.prompt ? { prompt: prompt.prompt } : {}), + ...(delivery.prompt ? { prompt: delivery.prompt } : {}), residualResources: [] } } catch (error) { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts index bc2b9601cebc..0b519274610a 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts @@ -30,6 +30,8 @@ export async function deliverWorkerDispatchPreamble(args: { dispatchCapability: string devMode: boolean | undefined requestId: string + /** This start launched the agent, so its first Enter can race the composer. */ + launchedTerminal: boolean }): Promise<{ prompt?: RuntimeTerminalSend['prompt'] structuredTurnStart?: WorkerTurnStartObservation @@ -71,11 +73,10 @@ export async function deliverWorkerDispatchPreamble(args: { } return { prompt: ( - await runtime.sendTerminalAgentPrompt( - terminalHandle, - preamble, - dispatchPreambleSendOptions(args.requestId) - ) + await runtime.sendTerminalAgentPrompt(terminalHandle, preamble, { + ...dispatchPreambleSendOptions(args.requestId), + retrySubmitAfterLaunch: args.launchedTerminal + }) ).prompt } } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/launched-agent-worker-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/launched-agent-worker-readiness.test.ts index 92ccc609b7d0..70d80ad72486 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/launched-agent-worker-readiness.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/launched-agent-worker-readiness.test.ts @@ -33,6 +33,11 @@ describe('first dispatch readiness of a freshly launched worker', () => { gate.resolve(COMPOSER_READY) await pending expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + 'term_worker', + expect.any(String), + expect.objectContaining({ retrySubmitAfterLaunch: true }) + ) } ) @@ -55,6 +60,11 @@ describe('first dispatch readiness of a freshly launched worker', () => { vi.spyOn(h.runtime, 'waitForFreshWorkerComposer') await h.startWorker({ terminal: 'term_worker' }) expect(h.runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + 'term_worker', + expect.any(String), + expect.objectContaining({ retrySubmitAfterLaunch: false }) + ) expect(h.runtime.waitForTerminal).toHaveBeenCalledWith( 'term_worker', expect.objectContaining({ condition: 'tui-idle' }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index 1d8b93562551..1392b6f8a3ec 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -233,6 +233,7 @@ export async function startLocalWorker(args: { dispatchCapability: capability, devMode: params.devMode, requestId: orchestrationMutation?.requestId ?? started.dispatch.id, + launchedTerminal: !params.terminal, agent: agent ?? null, setupReceipt, launchReceipt: launch.receipt, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts index 7e9118daffb5..6d3d848ea3a0 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts @@ -34,6 +34,7 @@ export async function deliverAndSettleWorkerStartReadiness(args: { dispatchCapability: string devMode: boolean | undefined requestId: string + launchedTerminal: boolean agent: string | null setupReceipt: WorkerSetupReceipt launchReceipt: OrchestrationWorkerLaunchReceipt @@ -58,7 +59,8 @@ export async function deliverAndSettleWorkerStartReadiness(args: { coordinatorHandle: args.coordinatorHandle, dispatchCapability: args.dispatchCapability, devMode: args.devMode, - requestId: args.requestId + requestId: args.requestId, + launchedTerminal: args.launchedTerminal }) effects.push({ kind: 'dispatch_input', diff --git a/src/main/runtime/runtime-terminal-contracts.ts b/src/main/runtime/runtime-terminal-contracts.ts index e08fffa273cf..ed6a692f6ee9 100644 --- a/src/main/runtime/runtime-terminal-contracts.ts +++ b/src/main/runtime/runtime-terminal-contracts.ts @@ -216,6 +216,8 @@ export type RuntimeAgentPromptWriteOptions = Omit void diff --git a/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts index 84ad14770d1d..4e7c765d172b 100644 --- a/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts +++ b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts @@ -114,6 +114,29 @@ describe('post-paste submit retry Enter', () => { expect(vi.getTimerCount()).toBe(0) }) + it.each(['opencode', 'opencode2'] as const)( + '%s gets the retry Enter from its row, for the box that appears before Enter works', + async (agent) => { + const retryDelayMs = TUI_AGENT_CONFIG[agent].submitRetryDelayMs ?? 0 + const promise = pasteDraftWhenAgentReady({ + tabId: 'tab-1', + content: ISSUE_URL, + agent, + submit: true, + forcePaste: true + }) + await flushMicrotasks() + testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}\x1b[?25h`) + await flushMicrotasks() + await vi.advanceTimersByTimeAsync(POST_PASTE_SUBMIT_DELAY_MS) + + expect(enterWrites()).toHaveLength(1) + await vi.advanceTimersByTimeAsync(retryDelayMs) + await expect(promise).resolves.toBe(true) + expect(enterWrites()).toHaveLength(2) + } + ) + it('sends exactly one Enter for agents without a submit retry delay', async () => { const promise = pasteDraftWhenAgentReady({ tabId: 'tab-1', diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 6bc9b76404fa..89cf7296168e 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -3,6 +3,7 @@ import type { TuiAgent } from '../../../shared/tui-agent' import type { TerminalInputKind } from '../../../shared/terminal-input-kind' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout' +import { getAgentSubmitRetryDelayMs } from '../../../shared/agent-prompt-injection' import { useAppStore } from '@/store' import { inspectRuntimeTerminalProcess, @@ -227,7 +228,7 @@ async function sendBracketedPasteToAgent(args: { }): Promise { const { settings = useAppStore.getState().settings, ptyId, content, submit, agent } = args const { inputKind } = args - const submitRetryDelayMs = agent ? TUI_AGENT_CONFIG[agent]?.submitRetryDelayMs : undefined + const submitRetryDelayMs = getAgentSubmitRetryDelayMs(agent) try { // Why: paste + Enter (+ retry Enter) must be one transaction, or a concurrent // paste on this PTY can slip between them and submit a half-written prompt. diff --git a/src/shared/agent-prompt-injection.ts b/src/shared/agent-prompt-injection.ts index b085f6a90489..ae46a0e82eda 100644 --- a/src/shared/agent-prompt-injection.ts +++ b/src/shared/agent-prompt-injection.ts @@ -13,6 +13,11 @@ export function agentPromptTakesLeadLine(agent: TuiAgent | null | undefined): bo return !agent || TUI_AGENT_CONFIG[agent].pasteNeedsTypedRequest === true } +/** The agent row's one extra submit Enter, shared by the renderer's paste and worker start. */ +export function getAgentSubmitRetryDelayMs(agent: TuiAgent | null | undefined): number | undefined { + return agent ? TUI_AGENT_CONFIG[agent].submitRetryDelayMs : undefined +} + /** OMP recognizes a submitted bracketed paste only when Enter shares its PTY write. */ export function agentPromptSubmitJoinsPasteFrame(agent: TuiAgent | null | undefined): boolean { return agent === 'omp' diff --git a/src/shared/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts index 2c0f3feaf798..f8d4e48a8229 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -48,7 +48,7 @@ export type TuiAgentConfig = { * the input box. Present = a freshly launched worker waits for that marker alone before its task. */ composerReadyCaptures?: readonly string[] - /** Delay before one extra blind submit Enter, for agents that render their composer before Enter is live (codex); a no-op if the first Enter landed. */ + /** Delay before one extra blind submit Enter, for agents that render their composer before Enter is live (codex, opencode); a no-op if the first Enter landed. */ submitRetryDelayMs?: number /** Extra ms per logical prompt line before Enter, for TUIs that expand multiline paste slowly (antigravity). */ submitLineSettleMsPerLine?: number diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 70e54675fff4..63cec4a09bde 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -103,7 +103,10 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { 'opencode-1-18-32-timed-boot-slow', 'opencode-1-18-32-timed-boot-hidden-pane', 'opencode-1-18-32-timed-first-launch' - ] + ], + // Why: OpenCode 2 drops an Enter sent before its mode row is drawn, which can trail the box + // by over a second under load; a second Enter 2 s later submitted every stuck brief measured. + submitRetryDelayMs: 2000 }, // Why: opencode2 installs as a separate binary and uses the same prompt flags. // Its @opentui composer keeps the same cursor-gated paste signal. @@ -114,7 +117,9 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { promptInjectionMode: 'flag-prompt', draftPasteReadySignal: 'render-cursor-after-bracketed-paste', draftPasteReadyTimeoutMs: 20_000, - composerReadyCaptures: ['opencode-2-0-18-timed-boot-hidden-pane'] + composerReadyCaptures: ['opencode-2-0-18-timed-boot-hidden-pane'], + // Why: see opencode's row; the Enter drop was measured on 2.0.18. + submitRetryDelayMs: 2000 }, 'mimo-code': { detectCmd: 'mimo', From deb5c0633d3c106aa876bafe7d93346afe371881 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:01:07 -0700 Subject: [PATCH 6/9] fix(agents): the retry Enter never answers a prompt the agent is showing The OpenCode rows' retry Enter (one extra Enter 2 s after the first) is blind in the renderer's submit-after-ready paste, which reaches every OpenCode launch that pastes and submits a prompt. If OpenCode asked for a tool permission or a question inside those 2 s, the Enter could answer it. The renderer now reads the status the agent already reports for that pane (the same hook status the sidebar shows) before sending the retry. It skips the retry when the pane is waiting on the user (a permission or question prompt) or when a new turn started after the first Enter, since the first Enter then landed. With no reported status (status hooks off) it stays blind, as the worker-start retry does. Turn clocks are compared with each other, never with the renderer's clock, so a remote host's clock skew cannot flip the decision. Also adds the missing test for the worker-start retry's own permission guard: a pane that reports a permission prompt after the first Enter gets no second Enter. --- .../agent-prompt-launch-submit-retry.test.ts | 30 ++++++++++++++- .../agent-paste-draft-submit-retry.test.ts | 38 ++++++++++++++++++- .../src/lib/agent-paste-draft.test.ts | 4 +- src/renderer/src/lib/agent-paste-draft.ts | 7 +++- .../src/lib/agent-submit-retry-guard.test.ts | 27 +++++++++++++ .../src/lib/agent-submit-retry-guard.ts | 38 +++++++++++++++++++ .../runtime/runtime-terminal-inspection.ts | 5 ++- 7 files changed, 143 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/lib/agent-submit-retry-guard.test.ts create mode 100644 src/renderer/src/lib/agent-submit-retry-guard.ts diff --git a/src/main/runtime/agent-prompt-launch-submit-retry.test.ts b/src/main/runtime/agent-prompt-launch-submit-retry.test.ts index b744828c1a64..f0f5e280ac5d 100644 --- a/src/main/runtime/agent-prompt-launch-submit-retry.test.ts +++ b/src/main/runtime/agent-prompt-launch-submit-retry.test.ts @@ -3,6 +3,7 @@ import { resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt import type { TuiAgent } from '../../shared/tui-agent' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { createAgentPromptSubmissionRuntime } from './agent-prompt-submission-runtime-test-fixture' +import type { OrcaRuntimeService } from './orca-runtime' import { dispatchPreambleSendOptions } from './orchestration/preamble' vi.mock('../git/worktree', () => ({ @@ -28,8 +29,18 @@ vi.mock('../git/worktree', () => ({ const PROMPT = 'Task: reply with OK' -async function sendDispatch(agent: TuiAgent, retrySubmitAfterLaunch: boolean) { - const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime(() => {}, agent) +async function sendDispatch( + agent: TuiAgent, + retrySubmitAfterLaunch: boolean, + afterFirstEnter?: (runtime: OrcaRuntimeService) => void +) { + let entered = false + const { runtime, handle, writes } = await createAgentPromptSubmissionRuntime((rt, data) => { + if (data === '\r' && !entered) { + entered = true + afterFirstEnter?.(rt) + } + }, agent) const sent = runtime.sendTerminalAgentPrompt(handle, PROMPT, { ...dispatchPreambleSendOptions('request-1'), retrySubmitAfterLaunch @@ -86,6 +97,21 @@ describe('retry Enter on a launched worker first dispatch', () => { } ) + it('never answers a permission prompt drawn after the first Enter', async () => { + vi.useFakeTimers() + const { sent, enters } = await sendDispatch('opencode', true, (runtime) => { + runtime.onPtyData( + 'pty-prompt', + '\x1b]9999;{"state":"waiting","agentType":"opencode"}\x07', + Date.now() + ) + }) + + await vi.runAllTimersAsync() + await expect(sent).resolves.toMatchObject({ accepted: true }) + expect(enters()).toBe(1) + }) + it('leaves the first Enter standing when the pane exits before the retry', async () => { vi.useFakeTimers() const submitDelayMs = resolveAgentPromptSubmitDelayForAgent( diff --git a/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts index 4e7c765d172b..5beced64ae9f 100644 --- a/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts +++ b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts @@ -12,6 +12,8 @@ const testState = vi.hoisted(() => ({ settings: {} as Record, ptyIdsByTabId: { 'tab-1': ['pty-1'] } as Record, runtimePaneTitlesByTabId: {}, + terminalLayoutsByTabId: {}, + agentStatusByPaneKey: {}, tabsByWorktree: {} as Record, repos: [] as { id: string; connectionId: string | null; executionHostId?: string | null }[], worktreesByRepo: {} as Record @@ -44,7 +46,8 @@ vi.mock('@/components/terminal-pane/pty-pre-handler-buffer', () => ({ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ isRemoteRuntimePtyId: testState.isRemoteRuntimePtyId, sendRuntimePtyInputVerified: testState.sendRuntimePtyInputVerified, - inspectRuntimeTerminalProcess: testState.inspectRuntimeTerminalProcess + inspectRuntimeTerminalProcess: testState.inspectRuntimeTerminalProcess, + resolvePaneKeyForPtyId: () => PANE_KEY })) vi.mock('@/runtime/runtime-terminal-stream', () => ({ @@ -52,6 +55,7 @@ vi.mock('@/runtime/runtime-terminal-stream', () => ({ })) const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +const PANE_KEY = 'tab-1:leaf-1' const CODEX_COMPOSER_PROMPT_RENDER = '\x1b[1m›\x1b[0m Ask Codex to do anything' const RENDER_QUIET_MS = 1500 const ISSUE_URL = 'https://github.com/stablyai/orca/issues/123' @@ -70,6 +74,7 @@ describe('post-paste submit retry Enter', () => { testState.appState.tabsByWorktree = {} testState.appState.repos = [] testState.appState.worktreesByRepo = {} + testState.appState.agentStatusByPaneKey = {} testState.ptyObserver = null testState.unsubscribe.mockReset() testState.subscribeToPtyData.mockReset() @@ -137,6 +142,37 @@ describe('post-paste submit retry Enter', () => { } ) + it.each([ + ['a permission prompt', 'waiting'], + ['a started turn', 'working'] + ])('skips the retry Enter once the pane reports %s after the first Enter', async (_, state) => { + testState.sendRuntimePtyInputVerified.mockImplementation( + async (_settings: unknown, _ptyId: string, data: string) => { + if (data === '\r') { + const reported = { [PANE_KEY]: { state, stateStartedAt: 1 } } + testState.appState.agentStatusByPaneKey = reported + } + return true + } + ) + const promise = pasteDraftWhenAgentReady({ + tabId: 'tab-1', + content: ISSUE_URL, + agent: 'opencode', + submit: true, + forcePaste: true + }) + await flushMicrotasks() + testState.ptyObserver?.(`${DECSET_BRACKETED_PASTE}\x1b[?25h`) + await flushMicrotasks() + await vi.advanceTimersByTimeAsync( + POST_PASTE_SUBMIT_DELAY_MS + (TUI_AGENT_CONFIG.opencode.submitRetryDelayMs ?? 0) + ) + + await expect(promise).resolves.toBe(true) + expect(enterWrites()).toHaveLength(1) + }) + it('sends exactly one Enter for agents without a submit retry delay', async () => { const promise = pasteDraftWhenAgentReady({ tabId: 'tab-1', diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index 1cd214cca954..d0eab7fb9998 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -60,7 +60,9 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ // Why drop the kind: this suite pins write shapes; startup-draft-input-kind.test.ts pins kinds. sendRuntimePtyInputVerified: (settings: unknown, ptyId: string, data: string) => testState.sendRuntimePtyInputVerified(settings, ptyId, data), - inspectRuntimeTerminalProcess: testState.inspectRuntimeTerminalProcess + inspectRuntimeTerminalProcess: testState.inspectRuntimeTerminalProcess, + // No pane reports a status here, so the retry Enter stays blind. + resolvePaneKeyForPtyId: () => null })) vi.mock('@/runtime/runtime-terminal-stream', () => ({ diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 89cf7296168e..f143e31895d3 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -4,6 +4,7 @@ import type { TerminalInputKind } from '../../../shared/terminal-input-kind' import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout' import { getAgentSubmitRetryDelayMs } from '../../../shared/agent-prompt-injection' +import { isSubmitRetryStillOwed, readAgentStatusForPtyId } from './agent-submit-retry-guard' import { useAppStore } from '@/store' import { inspectRuntimeTerminalProcess, @@ -242,6 +243,8 @@ async function sendBracketedPasteToAgent(args: { // Enter arrive in the same PTY write. Split the submit into the next turn so // the TUI processes bracketed-paste termination before handling Enter. await new Promise((resolve) => window.setTimeout(resolve, POST_PASTE_SUBMIT_DELAY_MS)) + const statusBeforeSubmit = + submitRetryDelayMs === undefined ? undefined : readAgentStatusForPtyId(ptyId) const submitted = await sendRuntimePtyInputVerified(settings, ptyId, '\r', inputKind) if (submitRetryDelayMs !== undefined) { @@ -249,7 +252,9 @@ async function sendBracketedPasteToAgent(args: { // the first Enter; the retry is best-effort and never downgrades `submitted`. await new Promise((resolve) => window.setTimeout(resolve, submitRetryDelayMs)) try { - await sendRuntimePtyInputVerified(settings, ptyId, '\r', inputKind) + if (isSubmitRetryStillOwed(statusBeforeSubmit, readAgentStatusForPtyId(ptyId))) { + await sendRuntimePtyInputVerified(settings, ptyId, '\r', inputKind) + } } catch { // Why: a rejected retry leaves the first Enter's verdict untouched. } diff --git a/src/renderer/src/lib/agent-submit-retry-guard.test.ts b/src/renderer/src/lib/agent-submit-retry-guard.test.ts new file mode 100644 index 000000000000..6c0a8c084241 --- /dev/null +++ b/src/renderer/src/lib/agent-submit-retry-guard.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { isSubmitRetryStillOwed } from './agent-submit-retry-guard' + +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ resolvePaneKeyForPtyId: () => null })) + +function status( + state: AgentStatusEntry['state'], + turnStartedAt: number +): Pick { + return { state, stateStartedAt: turnStartedAt, turnStartedAt } +} + +describe('whether the blind retry Enter is still owed', () => { + it.each([ + ['no status reported (hooks off)', undefined, undefined, true], + ['an idle pane', status('done', 1), status('done', 1), true], + ['a turn that was already running', status('working', 5), status('working', 5), true], + ['a turn started after the first Enter', status('done', 1), status('working', 9), false], + ['a new turn replacing a running one', status('working', 5), status('working', 9), false], + ['a permission prompt', status('done', 1), status('waiting', 9), false], + ['a blocked pane', undefined, status('blocked', 9), false] + ])('%s', (_, before, now, owed) => { + expect(isSubmitRetryStillOwed(before, now)).toBe(owed) + }) +}) diff --git a/src/renderer/src/lib/agent-submit-retry-guard.ts b/src/renderer/src/lib/agent-submit-retry-guard.ts new file mode 100644 index 000000000000..c93efd17c92c --- /dev/null +++ b/src/renderer/src/lib/agent-submit-retry-guard.ts @@ -0,0 +1,38 @@ +import type { AgentStatusEntry } from '../../../shared/agent-status-types' +import { useAppStore } from '@/store' +import { resolvePaneKeyForPtyId } from '@/runtime/runtime-terminal-inspection' + +/** The hook-reported status of the pane a PTY backs; absent when the agent reports none. */ +export function readAgentStatusForPtyId(ptyId: string): AgentStatusEntry | undefined { + const state = useAppStore.getState() + const paneKey = resolvePaneKeyForPtyId(state.terminalLayoutsByTabId, ptyId) + return paneKey ? state.agentStatusByPaneKey[paneKey] : undefined +} + +type PaneTurnStatus = Pick + +/** + * Whether the row's blind retry Enter is still wanted. It must never answer a permission or + * question prompt, and it is moot once a turn started after the first Enter. With no reported + * status (hooks off) there is no evidence either way, so the retry stays blind, as in main. + */ +export function isSubmitRetryStillOwed( + beforeSubmit: PaneTurnStatus | undefined, + now: PaneTurnStatus | undefined +): boolean { + if (!now) { + return true + } + if (now.state === 'blocked' || now.state === 'waiting') { + return false + } + if (now.state !== 'working') { + return true + } + // Host-stamped turn clocks compared with each other, never with this renderer's clock. + return beforeSubmit?.state === 'working' && turnStartOf(beforeSubmit) === turnStartOf(now) +} + +function turnStartOf(entry: PaneTurnStatus): number { + return entry.turnStartedAt ?? entry.stateStartedAt +} diff --git a/src/renderer/src/runtime/runtime-terminal-inspection.ts b/src/renderer/src/runtime/runtime-terminal-inspection.ts index e235bb5adef0..6759b1a02c87 100644 --- a/src/renderer/src/runtime/runtime-terminal-inspection.ts +++ b/src/renderer/src/runtime/runtime-terminal-inspection.ts @@ -38,7 +38,10 @@ const paneOwnersByPtyIdByLayoutIdentity = new WeakMap< Map >() -function resolvePaneKeyForPtyId(layouts: TerminalLayoutsByTabId, ptyId: string): PaneKey | null { +export function resolvePaneKeyForPtyId( + layouts: TerminalLayoutsByTabId, + ptyId: string +): PaneKey | null { let paneOwnersByPtyId = paneOwnersByPtyIdByLayoutIdentity.get(layouts) if (!paneOwnersByPtyId) { paneOwnersByPtyId = new Map() From f5229eef7380c8a6525b1ab1bb42d4d23f67c819 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:08:19 -0700 Subject: [PATCH 7/9] fix(orchestration): an OpenCode worker's receipt says whether its task was submitted A freshly launched OpenCode worker's first dispatch reported "accepted" once the bytes were written, even when the brief stayed unsubmitted in the input box. Orca's OpenCode status plugin posts `working` for every real submit and nothing for a stuck brief (QA: 59-77 ms after the Enter; 10/10 forced-stuck briefs posted nothing), so with status hooks on that post is now the evidence: - the retry Enter is skipped when the first Enter already started a turn for this request, which also keeps it away from a user typing or a prompt that follows a landed submit; - the receipt is observable (provider `opencode`/`opencode2`, observation `supported`): a turn observed after either Enter reports the worker ready, and none within the existing observation window reports the existing `outcome_unknown` / `turn_start_unobserved` / `turn_unobserved`. The terminal and dispatch capability are kept, so the user can still press Enter and a later worker report settles the dispatch. OpenCode's boot `done` post with an empty prompt is not a turn start: only a `working` post after the first Enter, claimed by this request, counts. With status hooks off nothing changes (blind retry, `unsupported` receipt). Coordinator dispatch, re-dispatch and `terminal send` stay unchanged. The receipt's provider type is widened in typed code to the agents whose turn start Orca observes, including antigravity, which it already carried. --- .../agent-prompt-launch-submit-retry.test.ts | 7 + ...ent-prompt-launch-turn-observation.test.ts | 220 ++++++++++++++++++ .../agent-prompt-submission-verification.ts | 31 ++- ...rca-runtime-write-terminal-agent-prompt.ts | 111 ++++++--- src/shared/runtime-terminal-contracts.ts | 4 +- src/shared/tui-agent.ts | 6 + 6 files changed, 342 insertions(+), 37 deletions(-) create mode 100644 src/main/runtime/agent-prompt-launch-turn-observation.test.ts diff --git a/src/main/runtime/agent-prompt-launch-submit-retry.test.ts b/src/main/runtime/agent-prompt-launch-submit-retry.test.ts index f0f5e280ac5d..67fd9ded3c31 100644 --- a/src/main/runtime/agent-prompt-launch-submit-retry.test.ts +++ b/src/main/runtime/agent-prompt-launch-submit-retry.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as HooksSetting from '../../shared/agent-status-hooks-setting' import { resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt-injection' import type { TuiAgent } from '../../shared/tui-agent' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' @@ -6,6 +7,12 @@ import { createAgentPromptSubmissionRuntime } from './agent-prompt-submission-ru import type { OrcaRuntimeService } from './orca-runtime' import { dispatchPreambleSendOptions } from './orchestration/preamble' +// Status hooks off: these cases pin the blind retry; turn observation is covered separately. +vi.mock('../../shared/agent-status-hooks-setting', async (importOriginal) => ({ + ...(await importOriginal()), + isAgentStatusHooksEnabledForAgent: () => false +})) + vi.mock('../git/worktree', () => ({ listWorktrees: vi.fn().mockResolvedValue([ { diff --git a/src/main/runtime/agent-prompt-launch-turn-observation.test.ts b/src/main/runtime/agent-prompt-launch-turn-observation.test.ts new file mode 100644 index 000000000000..17c308857cb3 --- /dev/null +++ b/src/main/runtime/agent-prompt-launch-turn-observation.test.ts @@ -0,0 +1,220 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as HooksSetting from '../../shared/agent-status-hooks-setting' +import type { AgentStatusHooksSettings } from '../../shared/agent-status-hooks-setting' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { AGENT_PROMPT_TEST_WORKTREE_PATH } from './agent-prompt-submission-runtime-test-fixture' +import { resolveAgentPromptObservedProvider } from './agent-prompt-submission-verification' +import { OrcaRuntimeService } from './orca-runtime' +import { dispatchPreambleSendOptions } from './orchestration/preamble' +import { observeWorkerTurnStart } from './rpc/methods/orchestration/worker/worker-start-turn-observation' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' + +const hooks = vi.hoisted(() => ({ enabled: true })) + +vi.mock('../../shared/agent-status-hooks-setting', async (importOriginal) => ({ + ...(await importOriginal()), + isAgentStatusHooksEnabledForAgent: () => hooks.enabled +})) + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/launch-turn-observation', + isBare: false, + isMainWorktree: false + } + ]), + listWorktreesStrict: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/launch-turn-observation', + isBare: false, + isMainWorktree: false + } + ]) +})) + +const RETRY_DELAY_MS = TUI_AGENT_CONFIG.opencode.submitRetryDelayMs ?? 0 + +type HookRow = { state: 'done' | 'working'; stateStartedAt: number; prompt: string } + +/** + * An OpenCode pane whose status plugin reports through the hook store. `onEnter` runs on each + * Enter with its ordinal, so a test can post the status OpenCode would. + */ +async function createOpenCodePane(onEnter: (enter: number, hook: HookRow) => void) { + let handle = '' + // OpenCode's plugin posts `done` with an empty prompt when it boots. + const hook: HookRow = { state: 'done', stateStartedAt: Date.now(), prompt: '' } + const writes: string[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only repos, worktree meta and settings from this store fixture. + const runtime = new OrcaRuntimeService(makeStore() as never, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey: 'prompt-pane', + terminalHandle: handle, + state: hook.state, + prompt: hook.prompt, + agentType: 'opencode', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: hook.stateStartedAt + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), + write: (_ptyId, data) => { + writes.push(data) + if (data === '\r') { + onEnter(writes.filter((write) => write === '\r').length, hook) + } + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + handle = ( + await runtime.createTerminal(`path:${AGENT_PROMPT_TEST_WORKTREE_PATH}`, { + launchAgent: 'opencode' + }) + ).handle + const sent = runtime.sendTerminalAgentPrompt(handle, 'Task: reply with OK', { + ...dispatchPreambleSendOptions('request-1'), + retrySubmitAfterLaunch: true + }) + return { runtime, handle, sent, enters: () => writes.filter((data) => data === '\r').length } +} + +function postWorkingAfter(delayMs: number, hook: HookRow): void { + setTimeout(() => { + hook.state = 'working' + hook.stateStartedAt = Date.now() + hook.prompt = 'Task: reply with OK' + }, delayMs) +} + +// Why: OpenCode's plugin posts `working` 59-77 ms after every real submit and nothing for a brief +// stuck in its box, so on a launched worker's first dispatch it both skips the retry Enter and +// makes the receipt honest. +describe('turn observation on a launched OpenCode worker first dispatch', () => { + afterEach(() => { + hooks.enabled = true + vi.useRealTimers() + }) + + it('skips the retry Enter when the first Enter started a turn', async () => { + vi.useFakeTimers() + const { sent, enters } = await createOpenCodePane((enter, hook) => { + if (enter === 1) { + postWorkingAfter(100, hook) + } + }) + + await vi.runAllTimersAsync() + await expect(sent).resolves.toMatchObject({ + prompt: { provider: 'opencode', stages: ['input_accepted', 'turn_started'] } + }) + expect(enters()).toBe(1) + }) + + it('retries once and reports ready when only the retry Enter starts a turn', async () => { + vi.useFakeTimers() + const { runtime, handle, sent, enters } = await createOpenCodePane((enter, hook) => { + if (enter === 2) { + postWorkingAfter(100, hook) + } + }) + + await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS + 1_000) + const receipt = await sent + expect(enters()).toBe(2) + expect(receipt.prompt).toMatchObject({ provider: 'opencode', observation: 'supported' }) + const observing = observeWorkerTurnStart({ + runtime, + terminalHandle: handle, + prompt: receipt.prompt, + timeoutMs: 5_000 + }) + await vi.runAllTimersAsync() + await expect(observing).resolves.toMatchObject({ verdict: 'observed' }) + }) + + it('leaves the start unobserved when no Enter starts a turn', async () => { + vi.useFakeTimers() + const { runtime, handle, sent, enters } = await createOpenCodePane(() => {}) + + await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS + 1_000) + const receipt = await sent + expect(enters()).toBe(2) + const observing = observeWorkerTurnStart({ + runtime, + terminalHandle: handle, + prompt: receipt.prompt, + timeoutMs: 5_000 + }) + await vi.runAllTimersAsync() + await expect(observing).resolves.toMatchObject({ verdict: 'unobserved' }) + }) + + it('does not count the boot `done` post as a turn start', async () => { + vi.useFakeTimers() + const { sent, enters } = await createOpenCodePane((enter, hook) => { + if (enter === 1) { + setTimeout(() => { + hook.state = 'done' + hook.stateStartedAt = Date.now() + hook.prompt = '' + }, 100) + } + }) + + await vi.runAllTimersAsync() + await expect(sent).resolves.toMatchObject({ + prompt: { provider: 'opencode', stages: ['input_accepted'] } + }) + expect(enters()).toBe(2) + }) + + it('keeps the blind retry and an unsupported receipt with status hooks off', async () => { + vi.useFakeTimers() + hooks.enabled = false + const { runtime, handle, sent, enters } = await createOpenCodePane((enter, hook) => { + if (enter === 1) { + postWorkingAfter(100, hook) + } + }) + + await vi.runAllTimersAsync() + const receipt = await sent + expect(enters()).toBe(2) + expect(receipt.prompt).toMatchObject({ provider: 'unsupported', observation: 'unsupported' }) + await expect( + observeWorkerTurnStart({ runtime, terminalHandle: handle, prompt: receipt.prompt }) + ).resolves.toMatchObject({ verdict: 'unsupported' }) + }) +}) + +describe('which provider settles a sent prompt', () => { + const settings: AgentStatusHooksSettings = {} + + it.each([ + ['opencode', true, 'opencode'], + ['opencode2', true, 'opencode2'], + ['opencode', false, null], + ['aider', true, null], + ['codex', false, 'codex'] + ] as const)('%s, launched-worker first dispatch %s → %s', (agent, launched, provider) => { + expect( + resolveAgentPromptObservedProvider({ + foregroundAgent: null, + launchAgent: agent, + retrySubmitAfterLaunch: launched, + settings + }) + ).toBe(provider) + }) +}) diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts index ad7095abddca..6402d4a432c8 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -1,6 +1,10 @@ export { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets' import { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets' -import type { TuiAgent } from '../../shared/tui-agent' +import type { AgentPromptObservedProvider, TuiAgent } from '../../shared/tui-agent' +import { + isAgentStatusHooksEnabledForAgent, + type AgentStatusHooksSettings +} from '../../shared/agent-status-hooks-setting' export const AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS const AGENT_PROMPT_EFFECT_POLL_MS = 50 @@ -57,6 +61,31 @@ export function isTerminalSendSettlementAgent( return agent === 'antigravity' || agent === 'claude' || agent === 'codex' } +/** + * The provider whose turn start settles this prompt's receipt, or null when none can be observed. + * OpenCode's status plugin posts `working` on every real submit and nothing for a brief stuck in + * its box, so a launched worker's first dispatch is observable there while its hooks are on. + */ +export function resolveAgentPromptObservedProvider(args: { + foregroundAgent: TuiAgent | null | undefined + launchAgent: TuiAgent | null | undefined + retrySubmitAfterLaunch: boolean + settings: AgentStatusHooksSettings +}): AgentPromptObservedProvider | null { + if (isTerminalSendSettlementAgent(args.foregroundAgent)) { + return args.foregroundAgent + } + if (isTerminalSendSettlementAgent(args.launchAgent)) { + return args.launchAgent + } + const agent = args.foregroundAgent ?? args.launchAgent + return args.retrySubmitAfterLaunch && + (agent === 'opencode' || agent === 'opencode2') && + isAgentStatusHooksEnabledForAgent(args.settings, agent) + ? agent + : null +} + export function isAgentPromptStalledError(error: unknown): boolean { if (error instanceof Error && error.message === AGENT_PROMPT_STALLED_ERROR) { return true diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index 7b6960356dff..6ca33ef7ef5c 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -17,8 +17,9 @@ import { } from '../../shared/agent-prompt-injection' import type { AgentPromptWaitTextCache } from './agent-prompt-submission-verification' import { - isTerminalSendSettlementAgent, + isAgentPromptStalledError, resolveAgentPromptEffectTimeoutMs, + resolveAgentPromptObservedProvider, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' @@ -113,19 +114,71 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } } + const settlementAgent = + options.acceptQueued && options.requestId + ? resolveAgentPromptObservedProvider({ + foregroundAgent: this.ptysById.get(ptyId)?.foregroundAgent, + launchAgent: this.ptysById.get(ptyId)?.launchAgent, + retrySubmitAfterLaunch: options.retrySubmitAfterLaunch === true, + settings: this.store?.getSettings() + }) + : null + const acceptTurnStart = (evidence) => + this.acceptAgentPromptTurnStart( + ptyId, + generation, + options.requestId!, + baseline.workingSequence, + baseline.explicitWorkingStartedAt, + evidence + ) + // Registered before the retry wait, so a turn the first Enter started is this request's. + const registeredBeforeRetry = settlementAgent !== null && submitRetryDelayMs !== undefined + if (registeredBeforeRetry) { + this.registerAgentPromptRequest( + ptyId, + generation, + options.requestId, + baseline.workingSequence, + baseline.explicitWorkingStartedAt + ) + } let submits = 1 + let turnStartedBeforeRetry = false if (submitRetryDelayMs !== undefined) { try { - await waitForAgentPromptDelay(submitRetryDelayMs, options.signal) - assertAgentPromptRequestActive(options.signal) - this.assertAgentPromptGeneration(ptyId, generation) - // A permission prompt drawn after the first Enter must never be answered by the retry. - this.assertAgentPromptPermissionSafe( - permissionBaseline, - this.getAgentPromptActivity(handle, ptyId) - ) - if (this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, options.inputKind)) { - submits = 2 + if (registeredBeforeRetry) { + // An observed turn proves the first Enter landed; a stuck brief posts nothing. + turnStartedBeforeRetry = await verifyAgentPromptSubmission({ + baseline, + readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache), + acceptTurnStart, + allowOutputEvidence: false, + signal: options.signal, + timeoutMs: submitRetryDelayMs + }).then( + () => true, + (error) => { + if (isAgentPromptStalledError(error)) { + return false + } + throw error + } + ) + } else { + await waitForAgentPromptDelay(submitRetryDelayMs, options.signal) + } + if (!turnStartedBeforeRetry) { + assertAgentPromptRequestActive(options.signal) + this.assertAgentPromptGeneration(ptyId, generation) + // A permission prompt drawn after the first Enter must never be answered by the retry. + this.assertAgentPromptPermissionSafe( + permissionBaseline, + this.getAgentPromptActivity(handle, ptyId) + ) + if (this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, options.inputKind)) { + submits = 2 + } } } catch { // The retry is best-effort; a refused one leaves the first Enter's result standing. @@ -142,13 +195,6 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso return { submits } } const binding = this.getTerminalPromptRequestBinding(handle) - const foregroundAgent = this.ptysById.get(ptyId)?.foregroundAgent - const launchAgent = this.ptysById.get(ptyId)?.launchAgent - const settlementAgent = isTerminalSendSettlementAgent(foregroundAgent) - ? foregroundAgent - : isTerminalSendSettlementAgent(launchAgent) - ? launchAgent - : null const inputAccepted: RuntimeTerminalPromptDelivery = { requestId: options.requestId, stages: ['input_accepted'], @@ -173,26 +219,23 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso if (!settlementAgent) { return { submits, prompt: inputAccepted } } - this.registerAgentPromptRequest( - ptyId, - generation, - options.requestId, - baseline.workingSequence, - baseline.explicitWorkingStartedAt - ) + if (turnStartedBeforeRetry) { + return { submits, prompt: { ...inputAccepted, stages: ['input_accepted', 'turn_started'] } } + } + if (!registeredBeforeRetry) { + this.registerAgentPromptRequest( + ptyId, + generation, + options.requestId, + baseline.workingSequence, + baseline.explicitWorkingStartedAt + ) + } try { await verifyAgentPromptSubmission({ baseline, readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache), - acceptTurnStart: (evidence) => - this.acceptAgentPromptTurnStart( - ptyId, - generation, - options.requestId!, - baseline.workingSequence, - baseline.explicitWorkingStartedAt, - evidence - ), + acceptTurnStart, allowOutputEvidence: false, signal: options.signal, timeoutMs: options.observationTimeoutMs ?? effectTimeoutMs diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 63adc2d67f82..589d2435a9b8 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -10,7 +10,7 @@ import type { RuntimeMobileSessionTabsResult } from './runtime-session-contracts import type { TabGroupLayoutNode } from './tab-types' import type { TerminalExitCause } from './terminal-exit-cause' import type { TerminalPaneLayoutNode } from './terminal-tab-types' -import type { TuiAgent } from './tui-agent' +import type { AgentPromptObservedProvider, TuiAgent } from './tui-agent' export type RuntimeTerminalSummary = { handle: string @@ -217,7 +217,7 @@ export type RuntimeTerminalPromptStage = 'input_accepted' | 'turn_started' export type RuntimeTerminalPromptDelivery = { requestId: string stages: RuntimeTerminalPromptStage[] - provider: 'claude' | 'codex' | 'unsupported' | 'old-host' + provider: AgentPromptObservedProvider | 'unsupported' | 'old-host' observation: 'supported' | 'unsupported' | 'incarnation_replaced' | 'permission' processIncarnation: string generation: number diff --git a/src/shared/tui-agent.ts b/src/shared/tui-agent.ts index a98c45101fd6..649575480158 100644 --- a/src/shared/tui-agent.ts +++ b/src/shared/tui-agent.ts @@ -44,3 +44,9 @@ export type TuiAgent = | 'zcode' // ZCode (Z.ai `zcode` CLI) | 'prime-agent' // Prime Agent (Prime Intellect) | 'dsh' // DeepSeek Harness (`dsh`, launched through its `dsh-tui` terminal profile) + +/** Agents whose turn start Orca can observe for a prompt it sent. */ +export type AgentPromptObservedProvider = Extract< + TuiAgent, + 'antigravity' | 'claude' | 'codex' | 'opencode' | 'opencode2' +> From 07ff2e5214ec8a876b764a2bae864f8a6176b5f2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:10:45 -0700 Subject: [PATCH 8/9] test(orchestration): a hook permission prompt after the first Enter stops the OpenCode retry Enter Main and the renderer now skip the retry for the same two reasons: the pane is waiting on the user, or a turn started. This pins main's half with a hook-reported permission prompt, as OpenCode's plugin posts it. --- ...ent-prompt-launch-turn-observation.test.ts | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/agent-prompt-launch-turn-observation.test.ts b/src/main/runtime/agent-prompt-launch-turn-observation.test.ts index 17c308857cb3..98141a290e9e 100644 --- a/src/main/runtime/agent-prompt-launch-turn-observation.test.ts +++ b/src/main/runtime/agent-prompt-launch-turn-observation.test.ts @@ -39,7 +39,7 @@ vi.mock('../git/worktree', () => ({ const RETRY_DELAY_MS = TUI_AGENT_CONFIG.opencode.submitRetryDelayMs ?? 0 -type HookRow = { state: 'done' | 'working'; stateStartedAt: number; prompt: string } +type HookRow = { state: 'done' | 'working' | 'waiting'; stateStartedAt: number; prompt: string } /** * An OpenCode pane whose status plugin reports through the hook store. `onEnter` runs on each @@ -179,6 +179,24 @@ describe('turn observation on a launched OpenCode worker first dispatch', () => expect(enters()).toBe(2) }) + it('skips the retry Enter when OpenCode asks for permission after the first Enter', async () => { + vi.useFakeTimers() + const { sent, enters } = await createOpenCodePane((enter, hook) => { + if (enter === 1) { + setTimeout(() => { + hook.state = 'waiting' + hook.stateStartedAt = Date.now() + }, 100) + } + }) + + await vi.runAllTimersAsync() + await expect(sent).resolves.toMatchObject({ + prompt: { provider: 'opencode', observation: 'permission' } + }) + expect(enters()).toBe(1) + }) + it('keeps the blind retry and an unsupported receipt with status hooks off', async () => { vi.useFakeTimers() hooks.enabled = false From d0b92d044c1810002af8d9def657cc0643ed453a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:25:03 -0700 Subject: [PATCH 9/9] fix(orchestration): keep an OpenCode worker's receipt unsupported until a missing plugin can be told apart Step 2 made a launched OpenCode worker's receipt observable whenever status hooks were on in settings. But "on in settings" does not mean Orca's OpenCode plugin loaded: its install can fail silently, and the plugin's first post arrives only after a real submit (37-49 ms after the Enter, 8/8 panes), so a plugin that never loaded looks exactly like a stuck brief. On such a host every OpenCode worker start would have waited about 32 s longer and reported outcome_unknown for a worker that was working, which is worse than before. The receipt is back to what main reports for OpenCode (`unsupported`, so the start is ready). What stays is the useful part of the hook signal: during the retry delay Orca still watches for a turn this request started, and skips the retry Enter when it sees one. No post (a stuck brief, or a plugin that never loaded) simply means the retry goes out, as before. The watch registers the request only for that wait and always releases it, and a pane that exits during the wait leaves the first Enter's result standing, with hooks on or off. The receipt's provider type is back to its previous values, since nothing needs the widening now. --- .../agent-prompt-launch-submit-retry.test.ts | 7 - ...ent-prompt-launch-turn-observation.test.ts | 190 ++++++------------ .../agent-prompt-submission-verification.ts | 31 +-- ...rca-runtime-write-terminal-agent-prompt.ts | 158 +++++++++------ src/shared/runtime-terminal-contracts.ts | 4 +- src/shared/tui-agent.ts | 6 - 6 files changed, 153 insertions(+), 243 deletions(-) diff --git a/src/main/runtime/agent-prompt-launch-submit-retry.test.ts b/src/main/runtime/agent-prompt-launch-submit-retry.test.ts index 67fd9ded3c31..f0f5e280ac5d 100644 --- a/src/main/runtime/agent-prompt-launch-submit-retry.test.ts +++ b/src/main/runtime/agent-prompt-launch-submit-retry.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type * as HooksSetting from '../../shared/agent-status-hooks-setting' import { resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt-injection' import type { TuiAgent } from '../../shared/tui-agent' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' @@ -7,12 +6,6 @@ import { createAgentPromptSubmissionRuntime } from './agent-prompt-submission-ru import type { OrcaRuntimeService } from './orca-runtime' import { dispatchPreambleSendOptions } from './orchestration/preamble' -// Status hooks off: these cases pin the blind retry; turn observation is covered separately. -vi.mock('../../shared/agent-status-hooks-setting', async (importOriginal) => ({ - ...(await importOriginal()), - isAgentStatusHooksEnabledForAgent: () => false -})) - vi.mock('../git/worktree', () => ({ listWorktrees: vi.fn().mockResolvedValue([ { diff --git a/src/main/runtime/agent-prompt-launch-turn-observation.test.ts b/src/main/runtime/agent-prompt-launch-turn-observation.test.ts index 98141a290e9e..58dd847e46de 100644 --- a/src/main/runtime/agent-prompt-launch-turn-observation.test.ts +++ b/src/main/runtime/agent-prompt-launch-turn-observation.test.ts @@ -1,21 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type * as HooksSetting from '../../shared/agent-status-hooks-setting' -import type { AgentStatusHooksSettings } from '../../shared/agent-status-hooks-setting' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { AGENT_PROMPT_TEST_WORKTREE_PATH } from './agent-prompt-submission-runtime-test-fixture' -import { resolveAgentPromptObservedProvider } from './agent-prompt-submission-verification' import { OrcaRuntimeService } from './orca-runtime' import { dispatchPreambleSendOptions } from './orchestration/preamble' -import { observeWorkerTurnStart } from './rpc/methods/orchestration/worker/worker-start-turn-observation' import { makeStore } from './runtime-rpc-worktree-store-fixtures' -const hooks = vi.hoisted(() => ({ enabled: true })) - -vi.mock('../../shared/agent-status-hooks-setting', async (importOriginal) => ({ - ...(await importOriginal()), - isAgentStatusHooksEnabledForAgent: () => hooks.enabled -})) - vi.mock('../git/worktree', () => ({ listWorktrees: vi.fn().mockResolvedValue([ { @@ -42,35 +31,43 @@ const RETRY_DELAY_MS = TUI_AGENT_CONFIG.opencode.submitRetryDelayMs ?? 0 type HookRow = { state: 'done' | 'working' | 'waiting'; stateStartedAt: number; prompt: string } /** - * An OpenCode pane whose status plugin reports through the hook store. `onEnter` runs on each - * Enter with its ordinal, so a test can post the status OpenCode would. + * An OpenCode pane whose status plugin reports through the hook store. It has posted nothing + * before the first submit; `onEnter` runs on each Enter so a test can post what OpenCode would. */ -async function createOpenCodePane(onEnter: (enter: number, hook: HookRow) => void) { +async function createOpenCodePane(onEnter: (enter: number, post: (row: HookRow) => void) => void) { let handle = '' - // OpenCode's plugin posts `done` with an empty prompt when it boots. - const hook: HookRow = { state: 'done', stateStartedAt: Date.now(), prompt: '' } + let hook: HookRow | null = null const writes: string[] = [] // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only repos, worktree meta and settings from this store fixture. const runtime = new OrcaRuntimeService(makeStore() as never, undefined, { - getAgentStatusSnapshot: () => [ - { - paneKey: 'prompt-pane', - terminalHandle: handle, - state: hook.state, - prompt: hook.prompt, - agentType: 'opencode', - connectionId: null, - receivedAt: Date.now(), - stateStartedAt: hook.stateStartedAt - } - ] + getAgentStatusSnapshot: () => + hook + ? [ + { + paneKey: 'prompt-pane', + terminalHandle: handle, + state: hook.state, + prompt: hook.prompt, + agentType: 'opencode', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: hook.stateStartedAt + } + ] + : [] }) + const post = (row: HookRow): void => { + hook = row + } runtime.setPtyController({ spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), write: (_ptyId, data) => { writes.push(data) if (data === '\r') { - onEnter(writes.filter((write) => write === '\r').length, hook) + onEnter( + writes.filter((write) => write === '\r').length, + (row) => void setTimeout(() => post(row), 50) + ) } return true }, @@ -86,153 +83,82 @@ async function createOpenCodePane(onEnter: (enter: number, hook: HookRow) => voi ...dispatchPreambleSendOptions('request-1'), retrySubmitAfterLaunch: true }) - return { runtime, handle, sent, enters: () => writes.filter((data) => data === '\r').length } + return { runtime, sent, enters: () => writes.filter((data) => data === '\r').length } } -function postWorkingAfter(delayMs: number, hook: HookRow): void { - setTimeout(() => { - hook.state = 'working' - hook.stateStartedAt = Date.now() - hook.prompt = 'Task: reply with OK' - }, delayMs) -} +const WORKING = (): HookRow => ({ + state: 'working', + stateStartedAt: Date.now(), + prompt: 'Task: reply with OK' +}) +const UNSUPPORTED_RECEIPT = { prompt: { provider: 'unsupported', observation: 'unsupported' } } -// Why: OpenCode's plugin posts `working` 59-77 ms after every real submit and nothing for a brief -// stuck in its box, so on a launched worker's first dispatch it both skips the retry Enter and -// makes the receipt honest. -describe('turn observation on a launched OpenCode worker first dispatch', () => { - afterEach(() => { - hooks.enabled = true - vi.useRealTimers() - }) +// Why: OpenCode's plugin posts `working` right after every real submit and nothing for a brief +// stuck in its box, so a turn this request started makes the retry Enter unnecessary. The receipt +// stays unsupported: with no post before the first submit, a plugin that never loaded looks the +// same as a stuck brief. +describe('retry Enter skip on a launched OpenCode worker first dispatch', () => { + afterEach(() => vi.useRealTimers()) it('skips the retry Enter when the first Enter started a turn', async () => { vi.useFakeTimers() - const { sent, enters } = await createOpenCodePane((enter, hook) => { + const { sent, enters } = await createOpenCodePane((enter, post) => { if (enter === 1) { - postWorkingAfter(100, hook) + post(WORKING()) } }) await vi.runAllTimersAsync() - await expect(sent).resolves.toMatchObject({ - prompt: { provider: 'opencode', stages: ['input_accepted', 'turn_started'] } - }) + await expect(sent).resolves.toMatchObject(UNSUPPORTED_RECEIPT) expect(enters()).toBe(1) }) - it('retries once and reports ready when only the retry Enter starts a turn', async () => { + it('retries once when no turn starts, with the same receipt', async () => { vi.useFakeTimers() - const { runtime, handle, sent, enters } = await createOpenCodePane((enter, hook) => { - if (enter === 2) { - postWorkingAfter(100, hook) - } - }) + const { sent, enters } = await createOpenCodePane(() => {}) await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS + 1_000) - const receipt = await sent + await expect(sent).resolves.toMatchObject(UNSUPPORTED_RECEIPT) expect(enters()).toBe(2) - expect(receipt.prompt).toMatchObject({ provider: 'opencode', observation: 'supported' }) - const observing = observeWorkerTurnStart({ - runtime, - terminalHandle: handle, - prompt: receipt.prompt, - timeoutMs: 5_000 - }) - await vi.runAllTimersAsync() - await expect(observing).resolves.toMatchObject({ verdict: 'observed' }) }) - it('leaves the start unobserved when no Enter starts a turn', async () => { + it("does not count the plugin's first `done` post as a turn start", async () => { vi.useFakeTimers() - const { runtime, handle, sent, enters } = await createOpenCodePane(() => {}) - - await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS + 1_000) - const receipt = await sent - expect(enters()).toBe(2) - const observing = observeWorkerTurnStart({ - runtime, - terminalHandle: handle, - prompt: receipt.prompt, - timeoutMs: 5_000 - }) - await vi.runAllTimersAsync() - await expect(observing).resolves.toMatchObject({ verdict: 'unobserved' }) - }) - - it('does not count the boot `done` post as a turn start', async () => { - vi.useFakeTimers() - const { sent, enters } = await createOpenCodePane((enter, hook) => { + // The plugin's first post, `done` with an empty prompt, arrives just after the submit. + const { sent, enters } = await createOpenCodePane((enter, post) => { if (enter === 1) { - setTimeout(() => { - hook.state = 'done' - hook.stateStartedAt = Date.now() - hook.prompt = '' - }, 100) + post({ state: 'done', stateStartedAt: Date.now(), prompt: '' }) } }) await vi.runAllTimersAsync() - await expect(sent).resolves.toMatchObject({ - prompt: { provider: 'opencode', stages: ['input_accepted'] } - }) + await expect(sent).resolves.toMatchObject(UNSUPPORTED_RECEIPT) expect(enters()).toBe(2) }) it('skips the retry Enter when OpenCode asks for permission after the first Enter', async () => { vi.useFakeTimers() - const { sent, enters } = await createOpenCodePane((enter, hook) => { + const { sent, enters } = await createOpenCodePane((enter, post) => { if (enter === 1) { - setTimeout(() => { - hook.state = 'waiting' - hook.stateStartedAt = Date.now() - }, 100) + post({ state: 'waiting', stateStartedAt: Date.now(), prompt: '' }) } }) await vi.runAllTimersAsync() - await expect(sent).resolves.toMatchObject({ - prompt: { provider: 'opencode', observation: 'permission' } - }) + await expect(sent).resolves.toMatchObject(UNSUPPORTED_RECEIPT) expect(enters()).toBe(1) }) - it('keeps the blind retry and an unsupported receipt with status hooks off', async () => { + it('leaves the first Enter standing when the pane exits during the wait', async () => { vi.useFakeTimers() - hooks.enabled = false - const { runtime, handle, sent, enters } = await createOpenCodePane((enter, hook) => { + const { runtime, sent, enters } = await createOpenCodePane((enter) => { if (enter === 1) { - postWorkingAfter(100, hook) + setTimeout(() => void runtime.onPtyExit('pty-prompt', 0), 500) } }) await vi.runAllTimersAsync() - const receipt = await sent - expect(enters()).toBe(2) - expect(receipt.prompt).toMatchObject({ provider: 'unsupported', observation: 'unsupported' }) - await expect( - observeWorkerTurnStart({ runtime, terminalHandle: handle, prompt: receipt.prompt }) - ).resolves.toMatchObject({ verdict: 'unsupported' }) - }) -}) - -describe('which provider settles a sent prompt', () => { - const settings: AgentStatusHooksSettings = {} - - it.each([ - ['opencode', true, 'opencode'], - ['opencode2', true, 'opencode2'], - ['opencode', false, null], - ['aider', true, null], - ['codex', false, 'codex'] - ] as const)('%s, launched-worker first dispatch %s → %s', (agent, launched, provider) => { - expect( - resolveAgentPromptObservedProvider({ - foregroundAgent: null, - launchAgent: agent, - retrySubmitAfterLaunch: launched, - settings - }) - ).toBe(provider) + await expect(sent).resolves.toMatchObject({ accepted: true, ...UNSUPPORTED_RECEIPT }) + expect(enters()).toBe(1) }) }) diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts index 6402d4a432c8..ad7095abddca 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -1,10 +1,6 @@ export { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets' import { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets' -import type { AgentPromptObservedProvider, TuiAgent } from '../../shared/tui-agent' -import { - isAgentStatusHooksEnabledForAgent, - type AgentStatusHooksSettings -} from '../../shared/agent-status-hooks-setting' +import type { TuiAgent } from '../../shared/tui-agent' export const AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS const AGENT_PROMPT_EFFECT_POLL_MS = 50 @@ -61,31 +57,6 @@ export function isTerminalSendSettlementAgent( return agent === 'antigravity' || agent === 'claude' || agent === 'codex' } -/** - * The provider whose turn start settles this prompt's receipt, or null when none can be observed. - * OpenCode's status plugin posts `working` on every real submit and nothing for a brief stuck in - * its box, so a launched worker's first dispatch is observable there while its hooks are on. - */ -export function resolveAgentPromptObservedProvider(args: { - foregroundAgent: TuiAgent | null | undefined - launchAgent: TuiAgent | null | undefined - retrySubmitAfterLaunch: boolean - settings: AgentStatusHooksSettings -}): AgentPromptObservedProvider | null { - if (isTerminalSendSettlementAgent(args.foregroundAgent)) { - return args.foregroundAgent - } - if (isTerminalSendSettlementAgent(args.launchAgent)) { - return args.launchAgent - } - const agent = args.foregroundAgent ?? args.launchAgent - return args.retrySubmitAfterLaunch && - (agent === 'opencode' || agent === 'opencode2') && - isAgentStatusHooksEnabledForAgent(args.settings, agent) - ? agent - : null -} - export function isAgentPromptStalledError(error: unknown): boolean { if (error instanceof Error && error.message === AGENT_PROMPT_STALLED_ERROR) { return true diff --git a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts index 6ca33ef7ef5c..4692c5cc3260 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -15,11 +15,14 @@ import { getTerminalPasteIngestMs, resolveAgentPromptSubmitDelayForAgent } from '../../shared/agent-prompt-injection' -import type { AgentPromptWaitTextCache } from './agent-prompt-submission-verification' +import type { + AgentPromptActivity, + AgentPromptWaitTextCache +} from './agent-prompt-submission-verification' import { isAgentPromptStalledError, + isTerminalSendSettlementAgent, resolveAgentPromptEffectTimeoutMs, - resolveAgentPromptObservedProvider, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' @@ -114,61 +117,20 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } } - const settlementAgent = - options.acceptQueued && options.requestId - ? resolveAgentPromptObservedProvider({ - foregroundAgent: this.ptysById.get(ptyId)?.foregroundAgent, - launchAgent: this.ptysById.get(ptyId)?.launchAgent, - retrySubmitAfterLaunch: options.retrySubmitAfterLaunch === true, - settings: this.store?.getSettings() - }) - : null - const acceptTurnStart = (evidence) => - this.acceptAgentPromptTurnStart( - ptyId, - generation, - options.requestId!, - baseline.workingSequence, - baseline.explicitWorkingStartedAt, - evidence - ) - // Registered before the retry wait, so a turn the first Enter started is this request's. - const registeredBeforeRetry = settlementAgent !== null && submitRetryDelayMs !== undefined - if (registeredBeforeRetry) { - this.registerAgentPromptRequest( - ptyId, - generation, - options.requestId, - baseline.workingSequence, - baseline.explicitWorkingStartedAt - ) - } let submits = 1 - let turnStartedBeforeRetry = false if (submitRetryDelayMs !== undefined) { try { - if (registeredBeforeRetry) { - // An observed turn proves the first Enter landed; a stuck brief posts nothing. - turnStartedBeforeRetry = await verifyAgentPromptSubmission({ + if ( + !(await this.observeAgentPromptTurnStartWithin( + handle, + ptyId, + generation, baseline, - readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache), - acceptTurnStart, - allowOutputEvidence: false, - signal: options.signal, - timeoutMs: submitRetryDelayMs - }).then( - () => true, - (error) => { - if (isAgentPromptStalledError(error)) { - return false - } - throw error - } - ) - } else { - await waitForAgentPromptDelay(submitRetryDelayMs, options.signal) - } - if (!turnStartedBeforeRetry) { + waitTextCache, + submitRetryDelayMs, + options + )) + ) { assertAgentPromptRequestActive(options.signal) this.assertAgentPromptGeneration(ptyId, generation) // A permission prompt drawn after the first Enter must never be answered by the retry. @@ -195,6 +157,13 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso return { submits } } const binding = this.getTerminalPromptRequestBinding(handle) + const foregroundAgent = this.ptysById.get(ptyId)?.foregroundAgent + const launchAgent = this.ptysById.get(ptyId)?.launchAgent + const settlementAgent = isTerminalSendSettlementAgent(foregroundAgent) + ? foregroundAgent + : isTerminalSendSettlementAgent(launchAgent) + ? launchAgent + : null const inputAccepted: RuntimeTerminalPromptDelivery = { requestId: options.requestId, stages: ['input_accepted'], @@ -219,23 +188,26 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso if (!settlementAgent) { return { submits, prompt: inputAccepted } } - if (turnStartedBeforeRetry) { - return { submits, prompt: { ...inputAccepted, stages: ['input_accepted', 'turn_started'] } } - } - if (!registeredBeforeRetry) { - this.registerAgentPromptRequest( - ptyId, - generation, - options.requestId, - baseline.workingSequence, - baseline.explicitWorkingStartedAt - ) - } + this.registerAgentPromptRequest( + ptyId, + generation, + options.requestId, + baseline.workingSequence, + baseline.explicitWorkingStartedAt + ) try { await verifyAgentPromptSubmission({ baseline, readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache), - acceptTurnStart, + acceptTurnStart: (evidence) => + this.acceptAgentPromptTurnStart( + ptyId, + generation, + options.requestId!, + baseline.workingSequence, + baseline.explicitWorkingStartedAt, + evidence + ), allowOutputEvidence: false, signal: options.signal, timeoutMs: options.observationTimeoutMs ?? effectTimeoutMs @@ -262,4 +234,58 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso throw error } } + + /** + * Waits out the retry delay, returning early once this request's turn starts: a turn proves the + * first Enter landed. No post (a stuck brief, or hooks that never loaded) just means "retry". + */ + private async observeAgentPromptTurnStartWithin( + handle: string, + ptyId: string, + generation: number, + baseline: AgentPromptActivity, + waitTextCache: AgentPromptWaitTextCache, + timeoutMs: number, + options: RuntimeAgentPromptWriteOptions + ): Promise { + if (!options.requestId) { + await waitForAgentPromptDelay(timeoutMs, options.signal) + return false + } + const requestId = options.requestId + this.registerAgentPromptRequest( + ptyId, + generation, + requestId, + baseline.workingSequence, + baseline.explicitWorkingStartedAt + ) + try { + await verifyAgentPromptSubmission({ + baseline, + readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache), + acceptTurnStart: (evidence) => + this.acceptAgentPromptTurnStart( + ptyId, + generation, + requestId, + baseline.workingSequence, + baseline.explicitWorkingStartedAt, + evidence + ), + allowOutputEvidence: false, + signal: options.signal, + timeoutMs + }) + return true + } catch (error) { + if (isAgentPromptStalledError(error)) { + return false + } + throw error + } finally { + // The receipt does not observe this turn, so nothing may stay queued behind it. + this.forgetAgentPromptRequest(ptyId, generation, requestId) + } + } } diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 589d2435a9b8..63adc2d67f82 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -10,7 +10,7 @@ import type { RuntimeMobileSessionTabsResult } from './runtime-session-contracts import type { TabGroupLayoutNode } from './tab-types' import type { TerminalExitCause } from './terminal-exit-cause' import type { TerminalPaneLayoutNode } from './terminal-tab-types' -import type { AgentPromptObservedProvider, TuiAgent } from './tui-agent' +import type { TuiAgent } from './tui-agent' export type RuntimeTerminalSummary = { handle: string @@ -217,7 +217,7 @@ export type RuntimeTerminalPromptStage = 'input_accepted' | 'turn_started' export type RuntimeTerminalPromptDelivery = { requestId: string stages: RuntimeTerminalPromptStage[] - provider: AgentPromptObservedProvider | 'unsupported' | 'old-host' + provider: 'claude' | 'codex' | 'unsupported' | 'old-host' observation: 'supported' | 'unsupported' | 'incarnation_replaced' | 'permission' processIncarnation: string generation: number diff --git a/src/shared/tui-agent.ts b/src/shared/tui-agent.ts index 649575480158..a98c45101fd6 100644 --- a/src/shared/tui-agent.ts +++ b/src/shared/tui-agent.ts @@ -44,9 +44,3 @@ export type TuiAgent = | 'zcode' // ZCode (Z.ai `zcode` CLI) | 'prime-agent' // Prime Agent (Prime Intellect) | 'dsh' // DeepSeek Harness (`dsh`, launched through its `dsh-tui` terminal profile) - -/** Agents whose turn start Orca can observe for a prompt it sent. */ -export type AgentPromptObservedProvider = Extract< - TuiAgent, - 'antigravity' | 'claude' | 'codex' | 'opencode' | 'opencode2' ->