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, 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-prompt-launch-submit-retry.test.ts b/src/main/runtime/agent-prompt-launch-submit-retry.test.ts new file mode 100644 index 000000000000..f0f5e280ac5d --- /dev/null +++ b/src/main/runtime/agent-prompt-launch-submit-retry.test.ts @@ -0,0 +1,132 @@ +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 type { OrcaRuntimeService } from './orca-runtime' +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, + 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 + }) + 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('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( + 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/agent-prompt-launch-turn-observation.test.ts b/src/main/runtime/agent-prompt-launch-turn-observation.test.ts new file mode 100644 index 000000000000..58dd847e46de --- /dev/null +++ b/src/main/runtime/agent-prompt-launch-turn-observation.test.ts @@ -0,0 +1,164 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { AGENT_PROMPT_TEST_WORKTREE_PATH } from './agent-prompt-submission-runtime-test-fixture' +import { OrcaRuntimeService } from './orca-runtime' +import { dispatchPreambleSendOptions } from './orchestration/preamble' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' + +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' | 'waiting'; stateStartedAt: number; prompt: string } + +/** + * 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, post: (row: HookRow) => void) => void) { + let handle = '' + 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: () => + 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, + (row) => void setTimeout(() => post(row), 50) + ) + } + 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, sent, enters: () => writes.filter((data) => data === '\r').length } +} + +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` 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, post) => { + if (enter === 1) { + post(WORKING()) + } + }) + + await vi.runAllTimersAsync() + await expect(sent).resolves.toMatchObject(UNSUPPORTED_RECEIPT) + expect(enters()).toBe(1) + }) + + it('retries once when no turn starts, with the same receipt', async () => { + vi.useFakeTimers() + const { sent, enters } = await createOpenCodePane(() => {}) + + await vi.advanceTimersByTimeAsync(RETRY_DELAY_MS + 1_000) + await expect(sent).resolves.toMatchObject(UNSUPPORTED_RECEIPT) + expect(enters()).toBe(2) + }) + + it("does not count the plugin's first `done` post as a turn start", async () => { + vi.useFakeTimers() + // 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) { + post({ state: 'done', stateStartedAt: Date.now(), prompt: '' }) + } + }) + + await vi.runAllTimersAsync() + 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, post) => { + if (enter === 1) { + post({ state: 'waiting', stateStartedAt: Date.now(), prompt: '' }) + } + }) + + await vi.runAllTimersAsync() + await expect(sent).resolves.toMatchObject(UNSUPPORTED_RECEIPT) + expect(enters()).toBe(1) + }) + + it('leaves the first Enter standing when the pane exits during the wait', async () => { + vi.useFakeTimers() + const { runtime, sent, enters } = await createOpenCodePane((enter) => { + if (enter === 1) { + setTimeout(() => void runtime.onPtyExit('pty-prompt', 0), 500) + } + }) + + await vi.runAllTimersAsync() + await expect(sent).resolves.toMatchObject({ accepted: true, ...UNSUPPORTED_RECEIPT }) + expect(enters()).toBe(1) + }) +}) 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..7f8e7a18fa15 --- /dev/null +++ b/src/main/runtime/launched-agent-composer-readiness.test.ts @@ -0,0 +1,170 @@ +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)).toBe(lane) + }) +}) + +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..060e07498be9 --- /dev/null +++ b/src/main/runtime/launched-agent-composer-readiness.ts @@ -0,0 +1,46 @@ +/** + * 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' +> + +export function getLaunchedAgentReadinessLane(agent: TuiAgent): LaunchedAgentReadinessLane { + return TUI_AGENT_CONFIG[agent].composerReadyCaptures?.length ? 'composer-marker' : 'tui-idle' +} + +export function waitForLaunchedAgentComposer( + runtime: LaunchedAgentReadinessRuntime, + handle: string, + agent: TuiAgent, + timeoutMs: number +): Promise { + return getLaunchedAgentReadinessLane(agent) === 'composer-marker' + ? 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/opencode-worker-composer-transcript.test.ts b/src/main/runtime/opencode-worker-composer-transcript.test.ts new file mode 100644 index 000000000000..52aa2f4da86e --- /dev/null +++ b/src/main/runtime/opencode-worker-composer-transcript.test.ts @@ -0,0 +1,101 @@ +/** + * 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.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..71d97dedbf68 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 { @@ -182,7 +183,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM handle: string, agent: TuiAgent, timeoutMs: number - ): Promise { + ): Promise { const initialPtyId = this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId const ptyId = await waitForWorktreeStartupDraft( @@ -198,6 +199,8 @@ 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 } } protected sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void { 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..4692c5cc3260 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -11,11 +11,16 @@ import { AGENT_PROMPT_SUBMIT, agentPromptSubmitJoinsPasteFrame, getAgentPromptSubmitDelayMs, + getAgentSubmitRetryDelayMs, 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, verifyAgentPromptSubmission @@ -44,6 +49,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 +117,35 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso throw new Error(options.suffixFailureError ?? 'terminal_not_writable') } } + let submits = 1 + if (submitRetryDelayMs !== undefined) { + try { + if ( + !(await this.observeAgentPromptTurnStartWithin( + handle, + ptyId, + generation, + baseline, + 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. + 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 +154,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 +178,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 +186,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 +214,7 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso }) this.forgetAgentPromptRequest(ptyId, generation, options.requestId) return { - submits: 1, + submits, prompt: { ...inputAccepted, stages: ['input_accepted', 'turn_started'] @@ -183,16 +222,70 @@ 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' } } } 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/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..409c508b7f4c --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts @@ -0,0 +1,129 @@ +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(): OrcaRuntimeService { + const db = new OrchestrationDb(':memory:') + databases.push(db) + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + configureFederationWorkerRuntime(runtime) + 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() + + 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() + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + HANDLE, + expect.any(String), + expect.objectContaining({ retrySubmitAfterLaunch: true }) + ) + } + ) + + it('a reused --terminal keeps the tui-idle wait', async () => { + 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 + ) + 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' }) + ) + 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 () => { + const runtime = createWorkerHost() + 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 ca63c1fb46d7..394910b2fd7a 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' @@ -27,6 +24,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 +215,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 }) @@ -251,22 +250,20 @@ 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, - 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)', + devMode: params.devMode, + requestId: orchestrationMutation.requestId, + launchedTerminal: !params.terminal + }) effects.push({ kind: 'dispatch_input', role: 'agent', @@ -285,7 +282,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 0d80079c13d8..ed5c6ce6b4d5 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 @@ -29,6 +29,8 @@ export async function deliverWorkerDispatchPreamble(args: { coordinatorHandle: 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 @@ -69,11 +71,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 new file mode 100644 index 000000000000..70d80ad72486 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/launched-agent-worker-readiness.test.ts @@ -0,0 +1,83 @@ +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() + 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() + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + 'term_worker', + expect.any(String), + expect.objectContaining({ retrySubmitAfterLaunch: true }) + ) + } + ) + + 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('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.sendTerminalAgentPrompt).toHaveBeenCalledWith( + 'term_worker', + expect.any(String), + expect.objectContaining({ retrySubmitAfterLaunch: false }) + ) + 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, '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 7e10274435b5..d35c9c570c23 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 { waitForWorkerAgentReady } from '../../../../launched-agent-composer-readiness' type WorkerStartMutation = { callerFingerprint: string @@ -188,17 +189,11 @@ 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( - terminalHandle, - agent, - params.timeoutMs ?? 60_000 - ) - : await runtime.waitForTerminal(terminalHandle, { - condition: 'tui-idle', - timeoutMs: 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) { @@ -237,6 +232,7 @@ export async function startLocalWorker(args: { coordinatorHandle: params.from, 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 98ddbecd6030..39cf17fcf9a2 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 @@ -33,6 +33,7 @@ export async function deliverAndSettleWorkerStartReadiness(args: { coordinatorHandle: string devMode: boolean | undefined requestId: string + launchedTerminal: boolean agent: string | null setupReceipt: WorkerSetupReceipt launchReceipt: OrchestrationWorkerLaunchReceipt @@ -56,7 +57,8 @@ export async function deliverAndSettleWorkerStartReadiness(args: { taskSpec: task.spec, coordinatorHandle: args.coordinatorHandle, devMode: args.devMode, - requestId: args.requestId + requestId: args.requestId, + launchedTerminal: args.launchedTerminal }) effects.push({ kind: 'dispatch_input', 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 fc7c1f3b26cf..ffcfc97b602a 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 @@ -390,6 +390,32 @@ 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, '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/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/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/renderer/src/lib/agent-paste-draft-submit-retry.test.ts b/src/renderer/src/lib/agent-paste-draft-submit-retry.test.ts index 84ad14770d1d..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() @@ -114,6 +119,60 @@ 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.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 ca5f39304175..a93559fe0f30 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 feca855338a2..f143e31895d3 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -3,6 +3,8 @@ 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 { isSubmitRetryStillOwed, readAgentStatusForPtyId } from './agent-submit-retry-guard' import { useAppStore } from '@/store' import { inspectRuntimeTerminalProcess, @@ -126,9 +128,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?.() } @@ -228,7 +229,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. @@ -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() 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/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..f8d4e48a8229 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -43,7 +43,12 @@ export type TuiAgentConfig = { draftPasteReadySignal?: DraftPasteReadySignal /** Hard deadline for the agent's composer readiness signal. */ draftPasteReadyTimeoutMs?: number - /** 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. */ + /** + * 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, 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 d9fdabb14c34..63cec4a09bde 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -98,7 +98,15 @@ 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: 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. @@ -108,7 +116,10 @@ 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'], + // Why: see opencode's row; the Enter drop was measured on 2.0.18. + submitRetryDelayMs: 2000 }, 'mimo-code': { detectCmd: 'mimo', @@ -318,7 +329,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',