From 1ad459518bcf8ec020b1ed3552533b12b9131d05 Mon Sep 17 00:00:00 2001 From: m4air Date: Sat, 3 Oct 2026 15:51:35 -0700 Subject: [PATCH 1/9] fix(orchestration): keep pressing Enter until an OpenCode worker's task starts OpenCode 2 draws its input box before it has loaded its agent list from its server, and silently drops an Enter that arrives in that gap. On a loaded machine the gap outlasts the 0.5 s Orca waits after pasting, so a worker's task sat unsent in the box while worker-start reported it ready. OpenCode's turn start is now observed through its status hooks, like Codex and Claude. While no turn has started, the prompt verifier presses Enter again on an interval (per the agent row, opencode and opencode2) for as long as the pane's hooks are reporting, never past an approval or question prompt or a replaced pane, and at most a fixed number of times. A pane whose hooks never report keeps today's single Enter and unobserved receipt. Co-Authored-By: Claude --- ...t-prompt-opencode-resubmit-runtime.test.ts | 209 ++++++++++++++++ .../runtime/agent-prompt-resubmit.test.ts | 235 ++++++++++++++++++ src/main/runtime/agent-prompt-resubmit.ts | 49 ++++ .../agent-prompt-submission-verification.ts | 44 ++++ ...untime-agent-prompt-request-correlation.ts | 75 +++++- ...rca-runtime-write-terminal-agent-prompt.ts | 53 ++-- src/shared/runtime-terminal-contracts.ts | 3 +- src/shared/tui-agent-config-types.ts | 6 + src/shared/tui-agent-config.ts | 13 +- 9 files changed, 657 insertions(+), 30 deletions(-) create mode 100644 src/main/runtime/agent-prompt-opencode-resubmit-runtime.test.ts create mode 100644 src/main/runtime/agent-prompt-resubmit.test.ts create mode 100644 src/main/runtime/agent-prompt-resubmit.ts diff --git a/src/main/runtime/agent-prompt-opencode-resubmit-runtime.test.ts b/src/main/runtime/agent-prompt-opencode-resubmit-runtime.test.ts new file mode 100644 index 000000000000..1de5832f1a93 --- /dev/null +++ b/src/main/runtime/agent-prompt-opencode-resubmit-runtime.test.ts @@ -0,0 +1,209 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AGENT_PROMPT_TEST_WORKTREE_PATH } from './agent-prompt-submission-runtime-test-fixture' +import { OrcaRuntimeService } from './orca-runtime' +import { makeStore } from './runtime-rpc-worktree-store-fixtures' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' + +vi.mock('../git/worktree', () => ({ + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/prompt-verification', + isBare: false, + isMainWorktree: false + } + ]), + listWorktreesStrict: vi.fn().mockResolvedValue([ + { + path: '/tmp/worktree-a', + head: 'abc', + branch: 'feature/prompt-verification', + isBare: false, + isMainWorktree: false + } + ]) +})) + +const POLICY = TUI_AGENT_CONFIG.opencode.submitRetryUntilTurnStart! +const DISPATCH_OPTIONS = { + inputKind: 'driving' as const, + acceptQueued: true, + requestId: 'dispatch-1', + observationTimeoutMs: 0 +} + +type Hook = { state: 'done' | 'working' | 'waiting'; stateStartedAt: number } | null + +/** + * An OpenCode pane that drops every Enter before `honoredFromEnter`, the way OpenCode 2 drops one + * sent before its agent list loads; the honoured Enter starts a turn its status hook reports. + */ +async function createOpenCodePane(args: { + honoredFromEnter: number + hook: { current: Hook } + hooksEnabled?: boolean +}) { + let handle = '' + const writes: string[] = [] + const baseStore = makeStore() + const store = { + ...baseStore, + getSettings: () => ({ + ...baseStore.getSettings(), + ...(args.hooksEnabled === false ? { agentStatusHooksEnabled: false } : {}) + }) + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared worktree-store fixture covers every store call this runtime makes. + const runtime = new OrcaRuntimeService(store as never, undefined, { + getAgentStatusSnapshot: () => + args.hook.current + ? [ + { + paneKey: 'prompt-pane', + terminalHandle: handle, + state: args.hook.current.state, + prompt: '', + agentType: 'opencode', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: args.hook.current.stateStartedAt + } + ] + : [] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), + write: (_ptyId, data) => { + writes.push(data) + if (data === '\r' && writes.filter((w) => w === '\r').length >= args.honoredFromEnter) { + args.hook.current = { state: 'working', stateStartedAt: Date.now() } + } + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + handle = ( + await runtime.createTerminal(`path:${AGENT_PROMPT_TEST_WORKTREE_PATH}`, { + launchAgent: 'opencode' + }) + ).handle + return { runtime, handle, writes, enters: () => writes.filter((w) => w === '\r').length } +} + +describe('OpenCode prompt submit keeps pressing Enter until its turn starts', () => { + afterEach(() => vi.useRealTimers()) + + it('observes the turn after the Enter OpenCode finally honours', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const hook: { current: Hook } = { current: { state: 'done', stateStartedAt: 500 } } + const pane = await createOpenCodePane({ honoredFromEnter: 3, hook }) + + const sent = sendBrief(pane) + await vi.runAllTimersAsync() + const receipt = (await sent).prompt! + expect(receipt).toMatchObject({ + provider: 'opencode', + observation: 'supported', + stages: ['input_accepted'] + }) + expect(pane.enters()).toBe(1) + + const observed = pane.runtime.observeTerminalAgentPrompt(pane.handle, receipt, 30_000) + await vi.advanceTimersByTimeAsync(POLICY.intervalMs * 2 + 200) + + await expect(observed).resolves.toMatchObject({ + stages: ['input_accepted', 'turn_started'], + observation: 'supported' + }) + expect(pane.enters()).toBe(3) + }) + + it('sends no extra Enter and reports the prompt when an approval is showing', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const hook: { current: Hook } = { current: { state: 'done', stateStartedAt: 500 } } + const pane = await createOpenCodePane({ honoredFromEnter: 99, hook }) + + const sent = sendBrief(pane) + await vi.runAllTimersAsync() + const observed = pane.runtime.observeTerminalAgentPrompt( + pane.handle, + (await sent).prompt!, + 30_000 + ) + hook.current = { state: 'waiting', stateStartedAt: Date.now() } + await vi.advanceTimersByTimeAsync(POLICY.intervalMs * 3) + + await expect(observed).resolves.toMatchObject({ observation: 'permission' }) + expect(pane.enters()).toBe(1) + }) + + it("keeps today's unobserved receipt and single Enter when status hooks are off", async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const pane = await createOpenCodePane({ + honoredFromEnter: 2, + hook: { current: null }, + hooksEnabled: false + }) + + const sent = sendBrief(pane) + await vi.runAllTimersAsync() + + expect((await sent).prompt).toMatchObject({ + provider: 'unsupported', + observation: 'unsupported' + }) + expect(pane.enters()).toBe(1) + }) + + it('never presses Enter blind and reports no observation when the pane never reports a hook', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const pane = await createOpenCodePane({ honoredFromEnter: 2, hook: { current: null } }) + + const sent = sendBrief(pane) + await vi.runAllTimersAsync() + const observed = pane.runtime.observeTerminalAgentPrompt( + pane.handle, + (await sent).prompt!, + 10_000 + ) + await vi.advanceTimersByTimeAsync(10_200) + + await expect(observed).resolves.toMatchObject({ + stages: ['input_accepted'], + observation: 'unsupported' + }) + expect(pane.enters()).toBe(1) + }) + + it("stops after the row's retry cap and leaves the turn honestly unobserved", async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const hook: { current: Hook } = { current: { state: 'done', stateStartedAt: 500 } } + const pane = await createOpenCodePane({ honoredFromEnter: 99, hook }) + + const sent = sendBrief(pane) + await vi.runAllTimersAsync() + const observed = pane.runtime.observeTerminalAgentPrompt( + pane.handle, + (await sent).prompt!, + 30_000 + ) + await vi.advanceTimersByTimeAsync(30_200) + + await expect(observed).resolves.toMatchObject({ + stages: ['input_accepted'], + observation: 'supported' + }) + expect(pane.enters()).toBe(1 + POLICY.maxRetries) + }) +}) + +function sendBrief(pane: Awaited>) { + return pane.runtime.sendTerminalAgentPrompt(pane.handle, 'the brief', DISPATCH_OPTIONS) +} diff --git a/src/main/runtime/agent-prompt-resubmit.test.ts b/src/main/runtime/agent-prompt-resubmit.test.ts new file mode 100644 index 000000000000..408bc722d82e --- /dev/null +++ b/src/main/runtime/agent-prompt-resubmit.test.ts @@ -0,0 +1,235 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createAgentPromptResubmit } from './agent-prompt-resubmit' +import { + resolveTerminalPromptSettlementAgent, + verifyAgentPromptSubmission, + type AgentPromptActivity +} from './agent-prompt-submission-verification' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' + +const POLICY = TUI_AGENT_CONFIG.opencode.submitRetryUntilTurnStart! + +function activity(overrides: Partial = {}): AgentPromptActivity { + return { + generation: 1, + permissionSequence: 0, + workingSequence: 0, + explicitWorkingStartedAt: null, + outputSequence: 0, + status: 'idle', + ...overrides + } +} + +/** A pane whose turn starts on the Nth Enter (the first Enter was the original submit). */ +function pane(turnStartsOnEnter: number) { + const state = { + current: activity(), + enters: 1, + hooksReporting: true, + blockWrites: false + } + const writeEnter = vi.fn(() => { + state.enters += 1 + if (state.enters >= turnStartsOnEnter) { + state.current = activity({ explicitWorkingStartedAt: 1_000, status: 'working' }) + } + return true + }) + const resubmit = createAgentPromptResubmit({ + agent: 'opencode', + hooksReporting: () => state.hooksReporting, + assertSafe: () => { + if (state.current.status === 'permission') { + throw new Error('agent_prompt_blocked') + } + }, + writeEnter + }) + return { state, writeEnter, resubmit } +} + +function verify(p: ReturnType, timeoutMs = 30_000) { + return verifyAgentPromptSubmission({ + baseline: activity(), + readActivity: () => p.state.current, + allowOutputEvidence: false, + timeoutMs, + resubmit: p.resubmit + }) +} + +describe('agent prompt resubmit', () => { + afterEach(() => vi.useRealTimers()) + + it('is configured only for agents whose row asks for it', () => { + const noop = { hooksReporting: () => true, assertSafe: () => {}, writeEnter: () => true } + expect(createAgentPromptResubmit({ agent: 'opencode', ...noop })).toBeDefined() + expect(createAgentPromptResubmit({ agent: 'opencode2', ...noop })).toBeDefined() + for (const agent of ['claude', 'codex', 'antigravity', 'grok'] as const) { + expect(createAgentPromptResubmit({ agent, ...noop })).toBeUndefined() + } + expect(createAgentPromptResubmit({ agent: null, ...noop })).toBeUndefined() + }) + + it('sends nothing when the first Enter started the turn', async () => { + vi.useFakeTimers() + const p = pane(1) + p.state.current = activity({ explicitWorkingStartedAt: 1_000, status: 'working' }) + + await expect(verify(p)).resolves.toBeUndefined() + expect(p.writeEnter).not.toHaveBeenCalled() + }) + + it.each([2, 3, POLICY.maxRetries + 1])( + 'keeps pressing Enter on the interval until the turn starts on Enter %i', + async (enter) => { + vi.useFakeTimers() + const p = pane(enter) + const verification = verify(p) + + await vi.advanceTimersByTimeAsync(POLICY.intervalMs * (enter - 1) + 100) + + await expect(verification).resolves.toBeUndefined() + expect(p.writeEnter).toHaveBeenCalledTimes(enter - 1) + } + ) + + it('waits a full interval before the first extra Enter', async () => { + vi.useFakeTimers() + const p = pane(99) + void verify(p).catch(() => {}) + + await vi.advanceTimersByTimeAsync(POLICY.intervalMs - 100) + expect(p.writeEnter).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(200) + expect(p.writeEnter).toHaveBeenCalledTimes(1) + }) + + it('stops at maxRetries and reports the turn as unobserved once the budget ends', async () => { + vi.useFakeTimers() + const p = pane(99) + const verification = verify(p, 30_000) + const settled = expect(verification).rejects.toThrow('agent_prompt_stalled') + + await vi.advanceTimersByTimeAsync(30_100) + + await settled + expect(p.writeEnter).toHaveBeenCalledTimes(POLICY.maxRetries) + }) + + it('never presses Enter while the pane has no hook status, so a hidden prompt is never answered', async () => { + vi.useFakeTimers() + const p = pane(2) + p.state.hooksReporting = false + const verification = verify(p, 10_000) + const settled = expect(verification).rejects.toThrow('agent_prompt_stalled') + + await vi.advanceTimersByTimeAsync(10_100) + + await settled + expect(p.writeEnter).not.toHaveBeenCalled() + }) + + it('resumes once hooks start reporting mid-wait', async () => { + vi.useFakeTimers() + const p = pane(2) + p.state.hooksReporting = false + const verification = verify(p) + + await vi.advanceTimersByTimeAsync(POLICY.intervalMs * 2) + expect(p.writeEnter).not.toHaveBeenCalled() + p.state.hooksReporting = true + await vi.advanceTimersByTimeAsync(POLICY.intervalMs + 100) + + await expect(verification).resolves.toBeUndefined() + expect(p.writeEnter).toHaveBeenCalledTimes(1) + }) + + it('stops without another Enter when a permission prompt appears mid-wait', async () => { + vi.useFakeTimers() + const p = pane(99) + const verification = verify(p) + const settled = expect(verification).rejects.toThrow('agent_prompt_blocked') + + await vi.advanceTimersByTimeAsync(POLICY.intervalMs + 100) + expect(p.writeEnter).toHaveBeenCalledTimes(1) + p.state.current = activity({ status: 'permission' }) + await vi.advanceTimersByTimeAsync(POLICY.intervalMs * 3) + + await settled + expect(p.writeEnter).toHaveBeenCalledTimes(1) + }) + + it('refuses the Enter itself when a prompt appeared between the check and the write', async () => { + vi.useFakeTimers() + const writeEnter = vi.fn(() => true) + const resubmit = createAgentPromptResubmit({ + agent: 'opencode', + hooksReporting: () => true, + assertSafe: () => { + throw new Error('agent_prompt_blocked') + }, + writeEnter + })! + + await expect(resubmit.send()).rejects.toThrow('agent_prompt_blocked') + expect(writeEnter).not.toHaveBeenCalled() + }) + + it('stops without another Enter when the pane is replaced mid-wait', async () => { + vi.useFakeTimers() + const p = pane(99) + const verification = verify(p) + const settled = expect(verification).rejects.toThrow('terminal_handle_stale') + + await vi.advanceTimersByTimeAsync(POLICY.intervalMs + 100) + p.state.current = activity({ generation: 2 }) + await vi.advanceTimersByTimeAsync(POLICY.intervalMs * 3) + + await settled + expect(p.writeEnter).toHaveBeenCalledTimes(1) + }) + + it('routes each Enter through the caller serialisation when given one', async () => { + const order: string[] = [] + const resubmit = createAgentPromptResubmit({ + agent: 'opencode2', + hooksReporting: () => true, + assertSafe: () => order.push('check'), + writeEnter: () => { + order.push('write') + return true + }, + serialize: async (send) => { + order.push('lock') + return await send() + } + })! + + await expect(resubmit.send()).resolves.toBe(true) + expect(order).toEqual(['lock', 'check', 'write']) + }) +}) + +describe('resolveTerminalPromptSettlementAgent', () => { + const hooksOn = (): boolean => true + const hooksOff = (): boolean => false + + it('keeps the hook-and-title providers regardless of the hooks setting', () => { + expect(resolveTerminalPromptSettlementAgent('claude', undefined, hooksOff)).toBe('claude') + expect(resolveTerminalPromptSettlementAgent(undefined, 'codex', hooksOff)).toBe('codex') + expect(resolveTerminalPromptSettlementAgent('antigravity', null, hooksOff)).toBe('antigravity') + }) + + it('observes a retrying agent only while its status hooks are enabled', () => { + expect(resolveTerminalPromptSettlementAgent('opencode', undefined, hooksOn)).toBe('opencode') + expect(resolveTerminalPromptSettlementAgent(undefined, 'opencode2', hooksOn)).toBe('opencode2') + expect(resolveTerminalPromptSettlementAgent('opencode', undefined, hooksOff)).toBeNull() + }) + + it('prefers an established provider over a retrying one and ignores other agents', () => { + expect(resolveTerminalPromptSettlementAgent('opencode', 'codex', hooksOn)).toBe('codex') + expect(resolveTerminalPromptSettlementAgent('grok', 'cursor', hooksOn)).toBeNull() + }) +}) diff --git a/src/main/runtime/agent-prompt-resubmit.ts b/src/main/runtime/agent-prompt-resubmit.ts new file mode 100644 index 000000000000..f1aa8a184072 --- /dev/null +++ b/src/main/runtime/agent-prompt-resubmit.ts @@ -0,0 +1,49 @@ +import type { TuiAgent } from '../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' + +/** + * Extra Enters a prompt verifier may press while no turn start is observed, for TUIs that draw + * their composer before Enter is live (OpenCode 2 drops an Enter until its agent list loads, and + * the bracketed paste then sits in the composer unsent). + */ +export type AgentPromptResubmit = { + intervalMs: number + maxRetries: number + /** One Enter when it is safe; false when it held back for lack of hook evidence. */ + send: () => Promise +} + +export function getAgentSubmitRetryUntilTurnStart( + agent: TuiAgent | null | undefined +): { intervalMs: number; maxRetries: number } | undefined { + return agent ? TUI_AGENT_CONFIG[agent].submitRetryUntilTurnStart : undefined +} + +export function createAgentPromptResubmit(args: { + agent: TuiAgent | null | undefined + /** Whether this pane's status hooks are reporting right now. */ + hooksReporting: () => boolean + /** Throws when the pane was replaced or an approval or question prompt is showing. */ + assertSafe: () => void + writeEnter: () => boolean + /** Runs the send under the pane's prompt serialisation when the caller does not hold it. */ + serialize?: (send: () => Promise) => Promise +}): AgentPromptResubmit | undefined { + const policy = getAgentSubmitRetryUntilTurnStart(args.agent) + if (!policy) { + return undefined + } + const sendNow = async (): Promise => { + // Why: without hooks Orca cannot see an approval or question the Enter would answer. + if (!args.hooksReporting()) { + return false + } + args.assertSafe() + return args.writeEnter() + } + return { + intervalMs: policy.intervalMs, + maxRetries: policy.maxRetries, + send: () => (args.serialize ? args.serialize(sendNow) : sendNow()) + } +} diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts index ad7095abddca..f078ef85a0ce 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -1,6 +1,10 @@ export { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets' import { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets' import type { TuiAgent } from '../../shared/tui-agent' +import { + getAgentSubmitRetryUntilTurnStart, + type AgentPromptResubmit +} from './agent-prompt-resubmit' export const AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS const AGENT_PROMPT_EFFECT_POLL_MS = 50 @@ -42,6 +46,8 @@ type AgentPromptVerificationOptions = { allowOutputEvidence?: boolean signal?: AbortSignal timeoutMs?: number + /** Presses Enter again while no turn start is observed (see agent-prompt-resubmit.ts). */ + resubmit?: AgentPromptResubmit } export function resolveAgentPromptEffectTimeoutMs(agent: TuiAgent | null | undefined): number { @@ -57,6 +63,28 @@ export function isTerminalSendSettlementAgent( return agent === 'antigravity' || agent === 'claude' || agent === 'codex' } +/** + * The provider whose turn start settles this prompt's receipt. Agents whose row retries Enter until + * a turn starts are observed through their status hooks, so they qualify only while those are on. + */ +export function resolveTerminalPromptSettlementAgent( + foregroundAgent: TuiAgent | null | undefined, + launchAgent: TuiAgent | null | undefined, + isStatusHooksEnabled: (agent: TuiAgent) => boolean +): TuiAgent | null { + for (const agent of [foregroundAgent, launchAgent]) { + if (isTerminalSendSettlementAgent(agent)) { + return agent + } + } + for (const agent of [foregroundAgent, launchAgent]) { + if (agent && getAgentSubmitRetryUntilTurnStart(agent) && isStatusHooksEnabled(agent)) { + return agent + } + } + return null +} + export function isAgentPromptStalledError(error: unknown): boolean { if (error instanceof Error && error.message === AGENT_PROMPT_STALLED_ERROR) { return true @@ -90,6 +118,9 @@ export async function verifyAgentPromptSubmission( assertPromptNotBlocked(options.baseline, options.baseline) const deadline = Date.now() + (options.timeoutMs ?? AGENT_PROMPT_EFFECT_TIMEOUT_MS) + const { resubmit } = options + let resubmits = 0 + let lastSubmitAt = Date.now() while (Date.now() < deadline) { const current = options.readActivity() assertSamePromptGeneration(options.baseline, current) @@ -105,6 +136,19 @@ export async function verifyAgentPromptSubmission( ) { return } + // Why after the checks above: a changed pane or a drawn prompt has already thrown, so no + // extra Enter can land on a replaced process or answer an approval. + if ( + resubmit && + resubmits < resubmit.maxRetries && + Date.now() - lastSubmitAt >= resubmit.intervalMs + ) { + lastSubmitAt = Date.now() + if (await resubmit.send()) { + resubmits += 1 + } + continue + } await waitForAgentPromptPoll(options.signal) } diff --git a/src/main/runtime/orca-runtime-agent-prompt-request-correlation.ts b/src/main/runtime/orca-runtime-agent-prompt-request-correlation.ts index 9606d33e7dbf..edbd8ae04999 100644 --- a/src/main/runtime/orca-runtime-agent-prompt-request-correlation.ts +++ b/src/main/runtime/orca-runtime-agent-prompt-request-correlation.ts @@ -3,11 +3,19 @@ import type { RuntimeTerminalPromptDelivery } from '../../shared/runtime-types' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { TerminalHandleRecord } from './runtime-terminal-contracts' import type { + AgentPromptActivity, AgentPromptTurnStartEvidence, AgentPromptWaitTextCache } from './agent-prompt-submission-verification' import { verifyAgentPromptSubmission } from './agent-prompt-submission-verification' import { AgentPromptRequestCorrelation } from './agent-prompt-request-correlation' +import { + createAgentPromptResubmit, + getAgentSubmitRetryUntilTurnStart, + type AgentPromptResubmit +} from './agent-prompt-resubmit' +import { AGENT_PROMPT_SUBMIT } from '../../shared/agent-prompt-injection' +import type { TuiAgent } from '../../shared/tui-agent' export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWithSerializeAgentPromptSubmission { private readonly agentPromptCorrelation = new AgentPromptRequestCorrelation() @@ -19,6 +27,11 @@ export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWit record: TerminalHandleRecord leaf: RuntimeLeafRecord } + declare protected assertAgentPromptGeneration: (ptyId: string, expected: number) => void + declare protected assertAgentPromptPermissionSafe: ( + baseline: AgentPromptActivity, + current: AgentPromptActivity + ) => void getTerminalPromptRequestBinding(handle: string): { ptyId: string @@ -53,19 +66,21 @@ export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWit return { ...prompt, observation: 'incarnation_replaced' } } const waitTextCache: AgentPromptWaitTextCache = {} - const baseline = this.getAgentPromptActivity(handle, binding.ptyId, waitTextCache) + const current = this.getAgentPromptActivity(handle, binding.ptyId, waitTextCache) + const baseline = { + ...current, + workingSequence: prompt.baselineWorkingSequence, + ...(prompt.baselinePermissionSequence !== undefined + ? { permissionSequence: prompt.baselinePermissionSequence } + : {}), + ...(prompt.baselineExplicitWorkingStartedAt !== undefined + ? { explicitWorkingStartedAt: prompt.baselineExplicitWorkingStartedAt } + : {}) + } + const resubmitAgent = isTuiAgentProvider(prompt.provider) ? prompt.provider : null try { await verifyAgentPromptSubmission({ - baseline: { - ...baseline, - workingSequence: prompt.baselineWorkingSequence, - ...(prompt.baselinePermissionSequence !== undefined - ? { permissionSequence: prompt.baselinePermissionSequence } - : {}), - ...(prompt.baselineExplicitWorkingStartedAt !== undefined - ? { explicitWorkingStartedAt: prompt.baselineExplicitWorkingStartedAt } - : {}) - }, + baseline, readActivity: () => this.getAgentPromptActivity(handle, binding.ptyId, waitTextCache), acceptTurnStart: (evidence) => this.acceptAgentPromptTurnStart( @@ -80,13 +95,19 @@ export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWit allowHookEvidence: prompt.baselineExplicitWorkingStartedAt !== undefined, allowOutputEvidence: false, signal, - timeoutMs + timeoutMs, + resubmit: this.createObservedPromptResubmit(handle, binding, baseline, resubmitAgent) }) this.forgetAgentPromptRequest(binding.ptyId, binding.generation, prompt.requestId) return { ...prompt, stages: ['input_accepted', 'turn_started'], observation: 'supported' } } catch (error) { if (error instanceof Error && error.message === 'agent_prompt_stalled') { - return prompt + // Why: such a provider is observed only through its status hooks; a pane that never + // reported cannot show a turn, so its receipt must not read as observable-but-missing. + return getAgentSubmitRetryUntilTurnStart(resubmitAgent) && + this.getFreshExplicitAgentStatusForPty(handle, binding.ptyId) === null + ? { ...prompt, observation: 'unsupported' } + : prompt } if (error instanceof Error && error.message === 'agent_prompt_blocked') { this.forgetAgentPromptRequest(binding.ptyId, binding.generation, prompt.requestId) @@ -96,6 +117,28 @@ export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWit } } + /** Extra Enters for a prompt observed after its write, under the pane's prompt serialisation. */ + private createObservedPromptResubmit( + handle: string, + binding: { ptyId: string; generation: number }, + baseline: AgentPromptActivity, + agent: TuiAgent | null + ): AgentPromptResubmit | undefined { + const { ptyId, generation } = binding + return createAgentPromptResubmit({ + agent, + hooksReporting: () => this.getFreshExplicitAgentStatusForPty(handle, ptyId) !== null, + assertSafe: () => { + this.assertAgentPromptGeneration(ptyId, generation) + this.assertAgentPromptPermissionSafe(baseline, this.getAgentPromptActivity(handle, ptyId)) + }, + writeEnter: () => this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, 'driving') === true, + // Why: another prompt's paste may be mid-flight; an Enter between its paste and submit + // would send it early. + serialize: (send) => this.serializeAgentPromptSubmission(ptyId, generation, send) + }) + } + protected registerAgentPromptRequest( ptyId: string, generation: number, @@ -137,3 +180,9 @@ export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWit this.agentPromptCorrelation.clearForPty(ptyId) } } + +function isTuiAgentProvider( + provider: RuntimeTerminalPromptDelivery['provider'] +): provider is TuiAgent { + return provider !== 'unsupported' && provider !== 'old-host' +} 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..6aabdbe4fb4e 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -16,10 +16,12 @@ import { } from '../../shared/agent-prompt-injection' import type { AgentPromptWaitTextCache } from './agent-prompt-submission-verification' import { - isTerminalSendSettlementAgent, resolveAgentPromptEffectTimeoutMs, + resolveTerminalPromptSettlementAgent, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' +import { createAgentPromptResubmit } from './agent-prompt-resubmit' +import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission { protected async writeTerminalAgentPrompt( @@ -108,23 +110,45 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso } } const effectTimeoutMs = resolveAgentPromptEffectTimeoutMs(this.getPtyAgent(ptyId)) + const foregroundAgent = this.ptysById.get(ptyId)?.foregroundAgent + const launchAgent = this.ptysById.get(ptyId)?.launchAgent + let extraSubmits = 0 + // Already inside this pane's prompt serialisation, so the extra Enter writes directly. + const resubmit = submitWithPaste + ? undefined + : createAgentPromptResubmit({ + agent: foregroundAgent ?? launchAgent, + hooksReporting: () => this.getFreshExplicitAgentStatusForPty(handle, ptyId) !== null, + assertSafe: () => { + this.assertAgentPromptGeneration(ptyId, generation) + this.assertAgentPromptPermissionSafe( + permissionBaseline, + this.getAgentPromptActivity(handle, ptyId) + ) + }, + writeEnter: () => { + const written = + this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, options.inputKind) === true + extraSubmits += written ? 1 : 0 + return written + } + }) if (!options.acceptQueued || !options.requestId) { await verifyAgentPromptSubmission({ baseline, readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache), timeoutMs: effectTimeoutMs, - signal: options.signal + signal: options.signal, + resubmit }) - return { submits: 1 } + return { submits: 1 + extraSubmits } } const binding = this.getTerminalPromptRequestBinding(handle) - const foregroundAgent = this.ptysById.get(ptyId)?.foregroundAgent - const launchAgent = this.ptysById.get(ptyId)?.launchAgent - const settlementAgent = isTerminalSendSettlementAgent(foregroundAgent) - ? foregroundAgent - : isTerminalSendSettlementAgent(launchAgent) - ? launchAgent - : null + const settlementAgent = resolveTerminalPromptSettlementAgent( + foregroundAgent, + launchAgent, + (agent) => isAgentStatusHooksEnabledForAgent(this.store?.getSettings?.(), agent) + ) const inputAccepted: RuntimeTerminalPromptDelivery = { requestId: options.requestId, stages: ['input_accepted'], @@ -171,11 +195,12 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso ), allowOutputEvidence: false, signal: options.signal, - timeoutMs: options.observationTimeoutMs ?? effectTimeoutMs + timeoutMs: options.observationTimeoutMs ?? effectTimeoutMs, + resubmit }) this.forgetAgentPromptRequest(ptyId, generation, options.requestId) return { - submits: 1, + submits: 1 + extraSubmits, prompt: { ...inputAccepted, stages: ['input_accepted', 'turn_started'] @@ -183,12 +208,12 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso } } catch (error) { if (error instanceof Error && error.message === 'agent_prompt_stalled') { - return { submits: 1, prompt: inputAccepted } + return { submits: 1 + extraSubmits, prompt: inputAccepted } } if (error instanceof Error && error.message === 'agent_prompt_blocked') { this.forgetAgentPromptRequest(ptyId, generation, options.requestId) return { - submits: 1, + submits: 1 + extraSubmits, prompt: { ...inputAccepted, observation: 'permission' } } } diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index cb59809a0722..3565508e5577 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -223,7 +223,8 @@ export type RuntimeTerminalPromptStage = 'input_accepted' | 'turn_started' export type RuntimeTerminalPromptDelivery = { requestId: string stages: RuntimeTerminalPromptStage[] - provider: 'claude' | 'codex' | 'unsupported' | 'old-host' + /** The agent whose turn start Orca observes for this prompt, or why none is observed. */ + provider: TuiAgent | 'unsupported' | 'old-host' observation: 'supported' | 'unsupported' | 'incarnation_replaced' | 'permission' processIncarnation: string generation: number diff --git a/src/shared/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts index c340cc22b1d9..91e5a4fbf151 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -47,6 +47,12 @@ export type TuiAgentConfig = { composerReadyCaptures?: readonly string[] /** Delay before one extra blind submit Enter, for agents that render their composer before Enter is live (codex); a no-op if the first Enter landed. */ submitRetryDelayMs?: number + /** + * Main-process prompt submit: press Enter again every `intervalMs`, at most `maxRetries` times, + * until this pane's status hooks report the turn started. For TUIs that draw their composer + * before Enter is live. Never sent without hook evidence for the pane, or past a prompt. + */ + submitRetryUntilTurnStart?: { intervalMs: number; maxRetries: number } /** Extra ms per logical prompt line before Enter, for TUIs that expand multiline paste slowly (antigravity). */ submitLineSettleMsPerLine?: number /** Windows Shift+Enter encoding override; omitted agents keep the legacy Esc+CR path. */ diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 0d69189ea3ff..a29a9f788e0f 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -23,6 +23,11 @@ function resolveTuiAgentConfig(source: TuiAgentConfigSource): TuiAgentConfig { } } +// Why 1.5 s x 5: an Enter after the agent list loads always submitted in measured runs, and the +// load trailed the composer by up to 2 s on a loaded machine; 7.5 s leaves headroom inside the +// 30 s observation window without leaning on an Enter that a dialog could consume. +const OPENCODE_SUBMIT_RETRY = { intervalMs: 1_500, maxRetries: 5 } + const TUI_AGENT_CONFIG_SOURCE: Record = { claude: { detectCmd: 'claude', @@ -103,7 +108,10 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { 'opencode-1-18-32-timed-boot-slow', 'opencode-1-18-32-timed-boot-hidden-pane', 'opencode-1-18-32-timed-first-launch' - ] + ], + // Why: OpenCode 2 silently drops an Enter until it has loaded its agent list from its server, + // which can trail the composer by seconds on a loaded machine (STA-9359). + submitRetryUntilTurnStart: OPENCODE_SUBMIT_RETRY }, // Why: opencode2 installs as a separate binary and uses the same prompt flags. // Its @opentui composer keeps the same cursor-gated paste signal. @@ -114,7 +122,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { promptInjectionMode: 'flag-prompt', draftPasteReadySignal: 'render-cursor-after-bracketed-paste', draftPasteReadyTimeoutMs: 20_000, - composerReadyCaptures: ['opencode-2-0-18-timed-boot-hidden-pane'] + composerReadyCaptures: ['opencode-2-0-18-timed-boot-hidden-pane'], + submitRetryUntilTurnStart: OPENCODE_SUBMIT_RETRY }, 'mimo-code': { detectCmd: 'mimo', From ba906519f98a0278e597ad8da222058a25fa83e6 Mon Sep 17 00:00:00 2001 From: m4air Date: Sat, 3 Oct 2026 22:37:09 -0700 Subject: [PATCH 2/9] fix(opencode): give a worker its task only once OpenCode can submit it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode 2 draws its input box, and shows its cursor there, before its agent list has loaded from its server, and it silently drops an Enter that arrives in that gap. Orca pasted a worker's task on the box's cursor and pressed Enter half a second later, so on a loaded machine the task stayed unsent in the box while the worker read as started. OpenCode's paste readiness now also waits for the row under the input box (` · `), which OpenCode paints only once the agent list exists. The rule keys on the row's separator inside OpenCode's alternate screen, not on names or colours. Both the opencode and opencode2 rows use it, worker start and the renderer's draft paste alike. OpenCode 1 paints the row in the box's own frame, so it is ready on the same read as before. Recorded OpenCode 2.0.21 boots (cold, hidden pane, four at once, attached to a running server) are the evidence; two more show an Enter after the row submitting and one at the box being dropped. The earlier resubmit loop and the settlement-provider changes are removed. Co-Authored-By: Claude --- ...ode-2-0-21-timed-busy-standalone.meta.json | 9 + ...e-2-0-21-timed-busy-standalone.timing.json | 1 + .../opencode-2-0-21-timed-busy-standalone.txt | 1 + ...imed-cold-standalone-hidden-pane.meta.json | 9 + ...ed-cold-standalone-hidden-pane.timing.json | 1 + ...0-21-timed-cold-standalone-hidden-pane.txt | 1 + ...ode-2-0-21-timed-cold-standalone.meta.json | 9 + ...e-2-0-21-timed-cold-standalone.timing.json | 1 + .../opencode-2-0-21-timed-cold-standalone.txt | 1 + ...-2-0-21-timed-enter-at-agent-row.meta.json | 9 + ...-0-21-timed-enter-at-agent-row.timing.json | 1 + ...encode-2-0-21-timed-enter-at-agent-row.txt | 1 + ...encode-2-0-21-timed-enter-at-box.meta.json | 9 + ...code-2-0-21-timed-enter-at-box.timing.json | 1 + .../opencode-2-0-21-timed-enter-at-box.txt | 1 + ...ode-cmd-2-0-21-timed-warm-server.meta.json | 9 + ...e-cmd-2-0-21-timed-warm-server.timing.json | 1 + .../opencode-cmd-2-0-21-timed-warm-server.txt | 1 + ...de-2-0-21-timed-busy-standalone@agent.json | 13 + ...-2-0-21-timed-busy-standalone@unknown.json | 13 + ...med-cold-standalone-hidden-pane@agent.json | 13 + ...d-cold-standalone-hidden-pane@unknown.json | 13 + ...de-2-0-21-timed-cold-standalone@agent.json | 13 + ...-2-0-21-timed-cold-standalone@unknown.json | 13 + ...2-0-21-timed-enter-at-agent-row@agent.json | 15 ++ ...0-21-timed-enter-at-agent-row@unknown.json | 15 ++ ...ncode-2-0-21-timed-enter-at-box@agent.json | 13 + ...ode-2-0-21-timed-enter-at-box@unknown.json | 13 + ...de-cmd-2-0-21-timed-warm-server@agent.json | 13 + ...-cmd-2-0-21-timed-warm-server@unknown.json | 13 + ...t-prompt-opencode-resubmit-runtime.test.ts | 209 ---------------- .../runtime/agent-prompt-resubmit.test.ts | 235 ------------------ src/main/runtime/agent-prompt-resubmit.ts | 49 ---- .../agent-prompt-submission-verification.ts | 51 +--- ...draft-paste-ready-scanner-chunking.test.ts | 46 ++-- .../launched-agent-composer-readiness.test.ts | 35 ++- ...pencode-agent-row-enter-transcript.test.ts | 53 ++++ ...pencode-worker-composer-transcript.test.ts | 41 ++- ...untime-agent-prompt-request-correlation.ts | 75 +----- ...worktree-setup-and-startup-part-03.spec.ts | 7 +- ...rca-runtime-write-terminal-agent-prompt.ts | 53 ++-- .../readiness-census-transcript-catalog.ts | 2 + ...agent-paste-draft-readiness-budget.test.ts | 7 +- .../src/lib/agent-paste-draft.test.ts | 15 +- src/shared/draft-paste-ready-scanner.test.ts | 41 ++- src/shared/draft-paste-ready-scanner.ts | 53 +++- src/shared/runtime-terminal-contracts.ts | 3 +- src/shared/tui-agent-config-types.ts | 7 +- src/shared/tui-agent-config.ts | 36 ++- src/shared/tui-agent-startup.test.ts | 7 +- 50 files changed, 523 insertions(+), 728 deletions(-) create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.txt create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.txt create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.txt create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.txt create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.txt create mode 100644 src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.txt create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-busy-standalone@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-busy-standalone@unknown.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone-hidden-pane@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone-hidden-pane@unknown.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone@unknown.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-agent-row@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-agent-row@unknown.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-box@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-box@unknown.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-cmd-2-0-21-timed-warm-server@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-cmd-2-0-21-timed-warm-server@unknown.json delete mode 100644 src/main/runtime/agent-prompt-opencode-resubmit-runtime.test.ts delete mode 100644 src/main/runtime/agent-prompt-resubmit.test.ts delete mode 100644 src/main/runtime/agent-prompt-resubmit.ts create mode 100644 src/main/runtime/opencode-agent-row-enter-transcript.test.ts diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.meta.json new file mode 100644 index 000000000000..e7bbdcaee3ae --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-04T05:14:45.638746Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane), env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs. Why this run: one of four cold --standalone starts launched at the same moment (no CPU stress tool), so the agent list loads late: the agent/model row trails the box by 280 ms. No input typed.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.timing.json new file mode 100644 index 000000000000..96c3e2cb90cf --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":10650.1,"chunks":[[423.7,280],[427.9,53],[445.0,1024],[445.7,1024],[446.3,1024],[447.0,1024],[448.7,1024],[453.5,1024],[458.5,353],[461.4,244],[465.3,1024],[467.0,1024],[471.2,1024],[475.7,1024],[477.8,1024],[481.2,1024],[485.6,353],[492.3,13],[576.4,934],[576.9,938],[577.1,835],[589.7,173],[857.1,476],[876.1,476],[893.4,481],[916.8,490],[933.6,491],[949.4,497],[965.6,326],[982.9,326],[1000.7,326]]} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.txt b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.txt new file mode 100644 index 000000000000..03dbb9a5742d --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.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]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix broken tests" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/Users/uuuuu/orca-...xxxxxx/capture/workshift+tab agentsctrl+p commands2.0.21]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l…/uuuuu/xxxxxxx/xxxxxxxx/capture/work [?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.meta.json new file mode 100644 index 000000000000..be46f1aaaf40 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-04T05:13:03.308914Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane), env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs. Why this run: the same cold --standalone start answered like a hidden Orca pane (responder 'headless': silent on OSC colour queries). No input typed.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.timing.json new file mode 100644 index 000000000000..055a5e4e4d14 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":11423.8,"chunks":[[241.4,312],[242.6,37],[242.8,1024],[243.1,1024],[243.3,1024],[243.6,1024],[243.9,1024],[244.1,1024],[244.4,141],[261.2,1024],[261.6,1024],[261.9,1024],[262.2,1024],[262.6,1024],[263.0,937],[1267.3,1024],[1268.0,1024],[1268.6,1024],[1269.2,1024],[1269.7,1024],[1270.1,1024],[1270.6,353],[1275.2,13],[1313.7,934],[1314.2,938],[1314.6,835],[1331.9,173],[1347.7,476],[1364.9,476],[1382.0,481],[1399.7,490],[1418.0,491],[1435.8,497],[1453.5,497],[1470.7,497],[1489.3,497]]} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.txt b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-hidden-pane.txt new file mode 100644 index 000000000000..7b064f2c5da5 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone-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[?2027h[?2004h[?2026h[?25l                                        [?2026l[?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/uuuuu/orca-...xxxxxx/capture/workshift+tab agentsctrl+p commands2.0.21]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l…/uuuuu/xxxxxxx/xxxxxxxx/capture/work [?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.meta.json new file mode 100644 index 000000000000..08da9b6117a6 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-04T05:12:27.181737Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane), env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs. Why this run: a cold start of a fresh private server (--standalone, as the opencode2 row launches); the agent/model row under the input box (' \u00b7 ') is painted 47 ms after the box. Responder 'xterm' (Orca's visible pane). No input typed.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.timing.json new file mode 100644 index 000000000000..05da882f867e --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":15442.9,"chunks":[[294.0,280],[295.2,37],[296.0,16],[296.1,16],[296.2,16],[296.2,32],[296.2,24],[296.2,24],[296.3,24],[296.3,36],[296.4,72],[304.5,1024],[304.8,1024],[305.1,1024],[305.4,1024],[305.7,1024],[306.3,1024],[306.5,353],[308.7,13],[341.4,934],[341.8,938],[342.1,835],[356.6,173],[389.4,218],[407.9,314],[429.0,314],[447.9,319],[466.4,325],[484.7,326],[502.8,326],[520.8,326],[538.9,326],[746.5,265]]} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.txt b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.txt new file mode 100644 index 000000000000..f89b181f7f18 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-cold-standalone.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[?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]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix a TODO in the codebase" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/Users/uuuuu/orca-...xxxxxx/capture/workshift+tab agentsctrl+p commands2.0.21]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l…/uuuuu/xxxxxxx/xxxxxxxx/capture/work [?25h[?2026l[?2026h[?25l·LongCat 2.5 Preview FreeOpenCode Zen[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25lFledge Alpha Free OpenCode Zen [?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.meta.json new file mode 100644 index 000000000000..86917d47c311 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-04T05:15:09.440812Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane), env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs. Why this run: one of four simultaneous cold --standalone starts; a 32-line bracketed paste was written the moment the agent/model row was painted and Enter 37 ms after the paste (promptSentAtMs). OpenCode submitted it: the composer cleared and the brief and the reply were painted. Positive control for the agent-row signal.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.timing.json new file mode 100644 index 000000000000..8d5429838006 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":16093.9,"chunks":[[443.9,312],[446.1,5],[446.9,16],[447.0,16],[447.1,48],[447.1,72],[447.2,84],[447.3,24],[458.4,1024],[459.1,1024],[460.0,1024],[462.2,1024],[462.8,1024],[463.4,1024],[465.0,353],[465.4,13],[562.2,934],[562.5,938],[562.9,835],[579.1,173],[985.9,485],[1006.2,518],[1024.0,325],[1052.1,13],[1066.3,1016],[1067.0,1018],[1067.7,1016],[1067.9,1018],[1068.2,1016],[1068.4,1018],[1068.6,1018],[1068.9,1016],[1069.1,1018],[1069.3,1015],[1069.6,694],[1074.4,1022],[1075.0,1024],[1075.5,446],[1090.9,259],[1093.4,13],[1107.5,259],[1127.0,990],[1127.3,978],[1127.5,72],[1145.4,259],[1162.7,85],[1181.1,180],[1217.3,225],[1234.6,85],[1252.4,269],[1287.4,71],[1305.8,313],[1324.1,227],[1347.6,389],[1360.3,85],[1376.9,622],[1395.4,117],[1413.7,227],[1432.2,436],[1460.0,227],[1478.7,227],[1497.2,226],[1515.5,473],[1534.2,258],[1552.5,458],[1569.8,217],[1586.7,463],[1603.5,299],[1620.0,296],[1638.3,489],[1656.6,291],[1675.0,316],[1693.3,455],[1711.3,286],[1729.6,267],[1746.3,240],[1764.7,406],[1783.0,268],[1801.0,337],[1819.5,301],[1837.3,430],[1854.5,270],[1872.0,508],[1889.2,269],[1907.6,402],[1926.3,520],[1944.1,452],[1962.6,546],[1979.3,402],[1997.2,671],[2016.9,435],[2034.3,797],[2051.3,335],[2069.2,420],[2086.8,634],[2105.4,417],[2122.2,385],[2140.6,467],[2157.8,690],[2176.1,421],[2194.5,663],[2212.6,335],[2231.3,440],[2250.1,651],[2268.8,473],[2287.5,580],[2306.1,418],[2323.6,570],[2342.0,390],[2359.1,427],[2377.2,351],[2395.5,475],[2413.8,441],[2432.1,374],[2448.6,424],[2467.7,471],[2484.3,307],[2502.7,371],[2520.9,441],[2539.1,373],[2557.4,436],[2575.4,388],[2593.7,357],[2611.4,322],[2629.5,400],[2646.0,439],[2664.3,353],[2682.8,336],[2701.5,387],[2720.1,432],[2737.3,289],[2755.6,381],[2773.1,465],[2790.7,315],[2809.0,399],[2828.1,282],[2846.0,281],[2865.1,366],[2883.6,347],[2902.0,449],[2919.3,332],[2936.8,416],[2953.4,349],[2970.7,303],[2989.1,453],[3006.4,387],[3025.0,372],[3043.3,466],[3060.3,405],[3077.8,270],[3095.0,321],[3113.6,484],[3132.1,399],[3150.5,372],[3169.2,400],[3186.0,484],[3202.8,319],[3219.6,451],[3237.9,419],[3255.3,336],[3272.0,470],[3290.5,353],[3308.4,519],[3326.8,354],[3343.3,581],[3360.5,336],[3377.8,352],[3396.1,576],[3414.4,435],[3432.3,620],[3450.0,368],[3466.6,600],[3485.0,284],[3503.4,482],[3521.6,709],[3540.0,467],[3558.2,736],[3575.5,421],[3593.0,748],[3609.5,386],[3627.3,407],[3645.4,788],[3663.2,372],[3681.4,492],[3698.4,436],[3715.7,1024],[3716.2,1024],[3716.7,810],[3734.2,425],[3752.6,474],[3771.3,636],[3789.7,419],[3807.4,487],[3823.8,424],[3842.1,407],[3860.6,529],[3879.0,487],[3896.2,385],[3914.6,485],[3933.0,354],[3951.4,408],[3968.5,334],[3986.8,401],[4005.2,456],[4025.8,354],[4044.1,372],[4062.4,465],[4079.0,350],[4097.4,478],[4115.9,288],[4133.3,589],[4151.0,381],[4168.4,395],[4185.0,514],[4203.4,315],[4220.7,591],[4237.6,347],[4254.1,361],[4270.7,633],[4289.1,315],[4306.5,299],[4324.8,728],[4343.2,398],[4361.8,383],[4380.1,687],[4398.4,387],[4414.7,288],[4431.3,400],[4447.8,434],[4459.4,13],[4465.2,1022],[4466.0,1024],[4466.4,1023],[4466.8,554],[4481.9,747],[4498.8,683],[4515.5,688],[4533.6,716],[4552.0,635],[4569.4,685],[4586.8,501],[4605.1,405],[4623.5,423],[4642.0,537],[4660.3,375],[4678.7,458],[4697.2,573],[4715.6,492],[4734.1,411],[4752.7,443],[4771.5,412],[4789.4,227],[4808.0,362],[4826.8,295],[4845.7,344],[4863.8,263],[4881.2,263],[4900.0,263],[4936.9,263],[4954.7,263],[4973.1,263],[4991.4,263],[5009.8,263],[5031.5,263],[5049.4,263],[5067.8,263],[5086.2,263],[5104.6,263],[5121.4,263],[5139.2,263],[5157.6,263],[5175.9,263],[5193.5,263],[5212.1,263],[5230.4,263],[5248.6,263],[5267.1,263],[5285.5,263],[5304.2,263],[5322.5,263],[5339.2,263],[5357.4,263],[5393.9,263],[5410.5,263],[5426.7,263],[5463.2,263],[5499.0,263],[5517.2,263],[5535.6,263],[5663.5,195],[12390.3,32],[12390.8,159]],"promptSentAtMs":1031.8} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.txt b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.txt new file mode 100644 index 000000000000..73555e5e14ac --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.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[?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]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "What is the tech stack of this project?" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/Users/uuuuu/orca-...xxxxxx/capture/workshift+tab agentsctrl+p commands2.0.21]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l…/uuuuu/xxxxxxx/xxxxxxxx/capture/work [?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃[Pasted ~32 lines]  ┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l]0;OpenCode[?2026h[?25l┃ 2. filler line standing in for the dispatch preamble. ┃ 3. filler line standing in for the dispatch preamble. ┃ 4. filler line standing in for the dispatch preamble. ┃ 5. filler line standing in for the dispatch preamble. ┃ 6. filler line standing in for the dispatch preamble. ┃ 7. filler line standing in for the dispatch preamble. ┃ 8. filler line standing in for the dispatch preamble. ┃ 9. filler line standing in for the dispatch preamble. ┃ 10. filler line standing in for the dispatch preamble. ┃ 11. filler line standing in for the dispatch preamble. ┃ 12. filler line standing in for the dispatch preamble. ┃ 13. filler line standing in for the dispatch preamble. ┃ 14. filler line standing in for the dispatch preamble. ┃ 15. filler line standing in for the dispatch preamble. ┃ 16. filler line standing in for the dispatch preamble. ┃ 17. filler line standing in for the dispatch preamble. ┃ 18. filler line standing in for the dispatch preamble. ┃ 19. filler line standing in for the dispatch preamble. ┃ 20. filler line standing in for the dispatch preamble. ┃ 21. filler line standing in for the dispatch preamble. ┃ 22. filler line standing in for the dispatch preamble. ┃ 23. filler line standing in for the dispatch preamble. ┃ 24. filler line standing in for the dispatch preamble. ┃ 25. filler line standing in for the dispatch preamble. ┃ 26. filler line standing in for the dispatch preamble. ┃ 27. filler line standing in for the dispatch preamble. ┃ 28. filler line standing in for the dispatch preamble. ┃ 29. filler line standing in for the dispatch preamble. ┃ 30. filler line standing in for the dispatch preamble. ┃ Task: reply with the single word ORCAPING and nothing else.  ┃ ┃ ┃ ┃ ┃ Build · Fledge Alpha Free OpenCode Zen ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/Users/uuuuu/xxxxxxx/xxxxxxxx/capture/workshift+tab agentsctrl+p commands[?25h[?2026l[?2026h[?25l ⠋ New session  +  23456789. filer line standing in for the dispatch preamble. 012345678190123456782930. filler line sanding inf the dispatch preamble. Task: reply with the single word ORCAPING and nothing else. ┃ [?25h[?2026l[?2026h[?25l ⠋ New session [?25h[?2026l]0;OpenCode[?2026h[?25l ⠋ New session [?25h[?2026l[?2026h[?25l ⠋ New session ┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃ ■⬝⬝⬝⬝⬝⬝⬝ esc interrup [?25h[?2026l[?2026h[?25l ⠋ New session [?25h[?2026l[?2026h[?25l⠙[?25h[?2026l[?2026h[?25l■■⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l■■■⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠹[?25h[?2026l[?2026h[?25l■■■■⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l [?25h[?2026l[?2026h[?25l■■■■■⬝⬝⬝[?25h[?2026l[?2026h[?25l⠸ New session [?25h[?2026l[?2026h[?25l ■■■■■■⬝⬝[?25h[?2026l[?2026h[?25l⠸[?25h[?2026l[?2026h[?25l ⠸ New session ⬝■■■■■■⬝[?25h[?2026l[?2026h[?25l ⠸[?25h[?2026l[?2026h[?25l⠼ New session [?25h[?2026l[?2026h[?25l ⠼⬝⬝■■■■■■[?25h[?2026l[?2026h[?25l ⠼New session [?25h[?2026l[?2026h[?25l⠼ New session [?25h[?2026l[?2026h[?25l⠴ ew session [?25h[?2026l[?2026h[?25l ⠴ N⬝⬝⬝■■■■■[?25h[?2026l[?2026h[?25l ⠴ ew session [?25h[?2026l[?2026h[?25l Nw session ⬝⬝⬝⬝■■■■[?25h[?2026l[?2026h[?25l ⠦Ne[?25h[?2026l[?2026h[?25l New session ⬝⬝⬝⬝⬝■■■[?25h[?2026l[?2026h[?25l ⠦ New[?25h[?2026l[?2026h[?25lNew session [?25h[?2026l[?2026h[?25l ⠦ New ⬝⬝⬝⬝⬝⬝■■[?25h[?2026l[?2026h[?25l⠧ewsession [?25h[?2026l[?2026h[?25l ew ⬝⬝⬝⬝⬝⬝⬝■[?25h[?2026l[?2026h[?25l ⠧ New session [?25h[?2026l[?2026h[?25l⠧w s⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠇ se[?25h[?2026l[?2026h[?25l se⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠇ New sesion [?25h[?2026l[?2026h[?25l ⠇ses[?25h[?2026l[?2026h[?25l⠇Nses⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠏ ess[?25h[?2026l[?2026h[?25l⠏eess⬝⬝⬝⬝⬝⬝■■[?25h[?2026l[?2026h[?25l ⠏ssi[?25h[?2026l[?2026h[?25l⠏ Nssi⬝⬝⬝⬝⬝■■■[?25h[?2026l[?2026h[?25l ⠋wsi[?25h[?2026l[?2026h[?25l ⠋ Nesio[?25h[?2026l[?2026h[?25l⠋ sio⬝⬝⬝⬝■■■■[?25h[?2026l[?2026h[?25l ⠋ Newion[?25h[?2026l[?2026h[?25l⠋ ion⬝⬝⬝■■■■■[?25h[?2026l[?2026h[?25l ⠙Neson [?25h[?2026l[?2026h[?25l⠙ w on ⬝⬝■■■■■■[?25h[?2026l[?2026h[?25l ⠙Neeon  [?25h[?2026l[?2026h[?25l⠙ Nw sn  ⬝■■■■■■⬝[?25h[?2026l[?2026h[?25l ⠹esn  [?25h[?2026l[?2026h[?25l Nw se   [?25h[?2026l[?2026h[?25l ⠹ew   ■■■■■■⬝⬝[?25h[?2026l[?2026h[?25l⠹ N ss   [?25h[?2026l[?2026h[?25l⠹ewes   [?25h[?2026l[?2026h[?25l⠸ Ne si   [?25h[?2026l[?2026h[?25l ⠸wess   ■■■■■⬝⬝⬝[?25h[?2026l[?2026h[?25l Ne so   [?25h[?2026l[?2026h[?25l⠸w esi   ■■■■⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠼ess   [?25h[?2026l[?2026h[?25lNw esn   [?25h[?2026l[?2026h[?25l⠼ sesio   ■■■⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25lew sss    [?25h[?2026l[?2026h[?25lNesion   ■■⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠴w ss    [?25h[?2026l[?2026h[?25leesion    ■⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l sesi   [?25h[?2026l[?2026h[?25lsson    ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠦wi     [?25h[?2026l[?2026h[?25lsesson    [?25h[?2026l[?2026h[?25l io      ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25lessn    [?25h[?2026l[?2026h[?25lio       ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠧sssin    [?25h[?2026l[?2026h[?25lo     ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25len       [?25h[?2026l[?2026h[?25lsion      [?25h[?2026l[?2026h[?25ls       ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠇ion      [?25h[?2026l[?2026h[?25l         ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25lson     [?25h[?2026l[?2026h[?25l       ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠏i        [?25h[?2026l[?2026h[?25ln         ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l o       [?25h[?2026l[?2026h[?25l         [?25h[?2026l[?2026h[?25l         ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠋n        [?25h[?2026l[?2026h[?25l      ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠋        [?25h[?2026l[?2026h[?25l ⠋         ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠙      [?25h[?2026l[?2026h[?25l ⠙       ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠙     [?25h[?2026l[?2026h[?25l ⠙     [?25h[?2026l[?2026h[?25l⠙       ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠹       [?25h[?2026l[?2026h[?25l⠹ N       ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠹ N     [?25h[?2026l[?2026h[?25l⠹ N      ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠸ Ne    [?25h[?2026l[?2026h[?25l Ne    [?25h[?2026l[?2026h[?25l Ne       ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25lNew      [?25h[?2026l[?2026h[?25lNew    ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠼ew        [?25h[?2026l[?2026h[?25lew      ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l w    [?25h[?2026l[?2026h[?25lw s    [?25h[?2026l[?2026h[?25l⠼w s      ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠴ se    [?25h[?2026l[?2026h[?25l se    ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠴ ses    [?25h[?2026l[?2026h[?25l ⠴ses     ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠦Nses [?25h[?2026l[?2026h[?25l ⠦ ess   ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠦eess    [?25h[?2026l[?2026h[?25l ⠦ssi  [?25h[?2026l[?2026h[?25l⠦ Nssi   ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠧wsio [?25h[?2026l[?2026h[?25l ⠧ Nesio  ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠧ sio  [?25h[?2026l[?2026h[?25l ⠧ Newion ■⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠇ ion  [?25h[?2026l[?2026h[?25l ⠇Nson [?25h[?2026l[?2026h[?25l⠇ ew on  ■■⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠇Neeon  [?25h[?2026l[?2026h[?25l⠇ Nw sn  ■■■⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠏esn   [?25h[?2026l[?2026h[?25l Nw e   ■■■■⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠏es   [?25h[?2026l[?2026h[?25l ⠏ Nw s    [?25h[?2026l[?2026h[?25l⠏ewses   ■■■■■⬝⬝⬝[?25h[?2026l[?2026h[?25l⠋ Ne si   [?25h[?2026l[?2026h[?25l ⠋wess   ■■■■■■⬝⬝[?25h[?2026l[?2026h[?25l Ne so   [?25h[?2026l[?2026h[?25l⠋w es   ⬝■■■■■■⬝[?25h[?2026l[?2026h[?25l⠙essi   [?25h[?2026l[?2026h[?25lNw esn  [?25h[?2026l[?2026h[?25l⠙ sesio   ⬝⬝■■■■■■[?25h[?2026l[?2026h[?25lew s    [?25h[?2026l[?2026h[?25lNsesion   [?25h[?2026l[?2026h[?25l⠹w sss   [?25h[?2026l[?2026h[?25lei    45678910. filer line standing in for the dispatch preamble.11. filer line standing in for the dispatch preamble.2345678920212345678930Task: reply with he singlewd ORCAPING and nothing else.    ⠋ Thinking ⬝⬝⬝■■■■■[?25h[?2026l[?2026h[?25lesson    [?25h[?2026l[?2026h[?25l sesio    [?25h[?2026l[?2026h[?25lwsin     ⬝⬝⬝⬝■■■■[?25h[?2026l[?2026h[?25l⠸seso    [?25h[?2026l[?2026h[?25l sin      ⠙[?25h[?2026l[?2026h[?25lso   ⬝⬝⬝⬝⬝■■■[?25h[?2026l[?2026h[?25lesin     [?25h[?2026l[?2026h[?25lson      ⬝⬝⬝⬝⬝⬝■■[?25h[?2026l[?2026h[?25l⠼ssio      [?25h[?2026l[?2026h[?25len      ⠹[?25h[?2026l[?2026h[?25lsio      ⬝⬝⬝⬝⬝⬝⬝■[?25h[?2026l[?2026h[?25ln        [?25h[?2026l[?2026h[?25lsio     ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠴n      [?25h[?2026l[?2026h[?25ls        ⠸[?25h[?2026l[?2026h[?25lon        ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25li        [?25h[?2026l[?2026h[?25ln       ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠦o         [?25h[?2026l[?2026h[?25l        ⠼[?25h[?2026l[?2026h[?25l         ⬝⬝⬝⬝⬝⬝■■[?25h[?2026l[?2026h[?25ln      [?25h[?2026l[?2026h[?25l           ⬝⬝⬝⬝⬝■■■[?25h[?2026l[?2026h[?25l ⠧        [?25h[?2026l[?2026h[?25l ⠧       ⠴[?25h[?2026l[?2026h[?25l ⠧     ⬝⬝⬝⬝■■■■[?25h[?2026l[?2026h[?25l ⠧      [?25h[?2026l[?2026h[?25l ⠧      ⬝⬝⬝■■■■■[?25h[?2026l[?2026h[?25l ⠇       [?25h[?2026l[?2026h[?25l ⠇      ⠦[?25h[?2026l[?2026h[?25l ⠇      ⬝⬝■■■■■■[?25h[?2026l[?2026h[?25l⠇       [?25h[?2026l[?2026h[?25l⠇ N    [?25h[?2026l[?2026h[?25l⠇ N      ⬝■■■■■■⬝[?25h[?2026l[?2026h[?25l⠏ N       [?25h[?2026l[?2026h[?25l Ne     ⠧[?25h[?2026l[?2026h[?25l Ne      ■■■■■■⬝⬝[?25h[?2026l[?2026h[?25lNew      [?25h[?2026l[?2026h[?25lNew   [?25h[?2026l[?2026h[?25l⠋New     [?25h[?2026l[?2026h[?25lew       ⠇[?25h[?2026l]0;OpenCode[?2026h[?25l • New session       8910. filer line standing in for the dispatch preamble.11. filer line standing in for the dispatch preamble.12. filer line standing in for the dispatch preamble.13. filer line standing in for the dispatch preamble.4567892021222345678930Task: reply with he singlewd ORCAPING and nothing else.    + Thought · 848ms   Build · Fledge Alpha Free · 3.3s · 40.2 tok/s/Users/uuuuu/xxxxxxx/sxxxxxx/capture/work 7.1K (1%)[?25h[?2026l[?2026h[?25l • New session          [?25h[?2026l[?2026h[?25l • New session        [?25h[?2026l[?2026h[?25l • New session        ORCAPING[?25h[?2026l[?2026h[?25l • New session         [?25h[?2026l[?2026h[?25l • New session        [?25h[?2026l[?2026h[?25l • New session        [?25h[?2026l[?2026h[?25l • session    [?25h[?2026l[?2026h[?25l •Nees    [?25h[?2026l[?2026h[?25l • Nw sss  [?25h[?2026l[?2026h[?25l • New ssion     [?25h[?2026l[?2026h[?25l • New sesi  [?25h[?2026l[?2026h[?25l • New sss   [?25h[?2026l[?2026h[?25l • New esion   [?25h[?2026l[?2026h[?25l • New session  [?25h[?2026l[?2026h[?25l • New sessin [?25h[?2026l[?2026h[?25l • New sesson  [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l Nw sessi [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session  [?25h[?2026l[?2026h[?25l • New session  [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l   [?25h[?2026l]0;OC | Orca readiness capture[?2026h[?25lOrca readiess capture[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.meta.json new file mode 100644 index 000000000000..0e937e23c17c --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-04T05:15:47.772691Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane), env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs. Why this run: negative control. One of four simultaneous cold --standalone starts; the same paste was written at the box's show-cursor (the old signal) and Enter 23 ms after the paste (promptSentAtMs), 320 ms before the agent/model row. OpenCode dropped the Enter: the brief stayed in the composer as [Pasted ~32 lines] until recording stopped.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.timing.json new file mode 100644 index 000000000000..ae9945f14864 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":15670.2,"chunks":[[441.6,317],[446.3,80],[459.6,1024],[460.1,1024],[460.4,1024],[460.7,1024],[460.9,1024],[461.2,1024],[463.9,353],[465.2,24],[465.4,48],[465.4,108],[471.2,13],[476.5,1024],[477.2,1024],[478.2,1024],[479.3,1024],[480.1,1024],[480.3,1024],[480.6,353],[545.8,934],[547.1,938],[547.8,835],[575.2,349],[907.3,476],[926.2,476],[943.1,481],[958.6,496],[975.9,326],[994.0,326],[1012.7,326],[1030.8,326],[1047.9,326]],"promptSentAtMs":586.3} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.txt b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.txt new file mode 100644 index 000000000000..dbf77751ae39 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.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[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h]0;OpenCode[?2026h[?25l                                        [?2026l[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix broken tests" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/Users/uuuuu/orca-...xxxxxx/capture/workshift+tab agentsctrl+p commands2.0.21]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l[Pasted ~32 lines]  …/uuuuu/xxxxxxx/xxxxxxxx/capture/work [?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.meta.json b/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.meta.json new file mode 100644 index 000000000000..9ae8440a6b2f --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-04T05:14:16.961893Z", + "platform": "darwin", + "command": ["opencode", "--server", "http://127.0.0.1:49917"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane), env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs. Why this run: the plain opencode command attaching to a server that already has its agent list loaded: an unregistered 'opencode serve' started for the capture (throwaway password, stopped by PID after; the registered background service was never used) and attached with --server after one priming attach, standing in for the shared service the opencode row attaches to. The agent/model row trails the box by 28 ms. No input typed.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.timing.json b/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.timing.json new file mode 100644 index 000000000000..3e22360d4893 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":8188.3,"chunks":[[57.0,280],[58.0,101],[64.6,1024],[64.9,1024],[65.2,1024],[65.4,1024],[65.6,1024],[65.9,1024],[66.2,549],[69.3,13],[101.7,1024],[102.3,1024],[103.1,958],[103.5,986],[103.8,958],[104.0,1016],[104.3,874],[104.5,1024],[104.7,1024],[105.0,486],[117.4,173],[133.3,476],[149.9,476],[166.6,481],[188.1,490],[205.9,491],[223.9,497],[241.9,497],[259.9,497],[278.0,497]]} diff --git a/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.txt b/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.txt new file mode 100644 index 000000000000..46f22222c72f --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-cmd-2-0-21-timed-warm-server.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[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2026h[?25l                                        [?2026l[?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]0;OpenCode[?2026h[?25l                  ▄   █▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█  █ █ █ █ █▀▀▀ █ █ █  █ █ █ █ █▀▀▀  ▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀    ┃   ┃ Ask anything… "What is the tech stack of this project?"   ┃   ┃   ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀  /Users/uuuuu/orca-...xxxxxx/capture/work shift+tab agents ctrl+p commands              2.0.21  ]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l…/uuuuu/xxxxxxx/xxxxxxxx/capture/work [?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-busy-standalone@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-busy-standalone@agent.json new file mode 100644 index 000000000000..c0e1ce874c58 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-busy-standalone@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-16: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "17-30: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-16: verdict=pending:closed wait=pending", + "17-30: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-busy-standalone@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-busy-standalone@unknown.json new file mode 100644 index 000000000000..c6eff2391d36 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-busy-standalone@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-16: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "17-30: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-16: verdict=pending:open wait=ready@poll", + "17-30: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone-hidden-pane@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone-hidden-pane@agent.json new file mode 100644 index 000000000000..f717a3783625 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone-hidden-pane@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-21: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "22-35: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-21: verdict=pending:closed wait=pending", + "22-35: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone-hidden-pane@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone-hidden-pane@unknown.json new file mode 100644 index 000000000000..0289f95d468f --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone-hidden-pane@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-21: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "22-35: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-21: verdict=pending:open wait=ready@poll", + "22-35: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone@agent.json new file mode 100644 index 000000000000..6685798274bd --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-17: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "18-32: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-17: verdict=pending:closed wait=pending", + "18-32: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone@unknown.json new file mode 100644 index 000000000000..2c691e274e6d --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-cold-standalone@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-17: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "18-32: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-17: verdict=pending:open wait=ready@poll", + "18-32: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-agent-row@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-agent-row@agent.json new file mode 100644 index 000000000000..6fdc793fbf23 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-agent-row@agent.json @@ -0,0 +1,15 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-14: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "15-289: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "290-291: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-14: verdict=pending:closed wait=pending", + "15-289: verdict=ready-weak wait=ready@start", + "290-291: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-agent-row@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-agent-row@unknown.json new file mode 100644 index 000000000000..38207d3d3d4d --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-agent-row@unknown.json @@ -0,0 +1,15 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-14: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "15-289: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "290-291: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-14: verdict=pending:open wait=ready@poll", + "15-289: verdict=ready-weak wait=ready@start", + "290-291: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-box@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-box@agent.json new file mode 100644 index 000000000000..35730c38e833 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-box@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-11: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "12-32: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-11: verdict=pending:closed wait=pending", + "12-32: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-box@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-box@unknown.json new file mode 100644 index 000000000000..d6725c5512f0 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-box@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-11: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "12-32: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-11: verdict=pending:open wait=ready@poll", + "12-32: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-cmd-2-0-21-timed-warm-server@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-cmd-2-0-21-timed-warm-server@agent.json new file mode 100644 index 000000000000..508c910ccbe8 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-cmd-2-0-21-timed-warm-server@agent.json @@ -0,0 +1,13 @@ +{ + "description": "opencode recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-8: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "9-29: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-8: verdict=pending:closed wait=pending", + "9-29: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-cmd-2-0-21-timed-warm-server@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-cmd-2-0-21-timed-warm-server@unknown.json new file mode 100644 index 000000000000..035e7fd22ca2 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-cmd-2-0-21-timed-warm-server@unknown.json @@ -0,0 +1,13 @@ +{ + "description": "opencode recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-8: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "9-29: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + ], + "clockless": [ + "0-8: verdict=pending:open wait=ready@poll", + "9-29: verdict=ready-weak wait=ready@start" + ] + } +} diff --git a/src/main/runtime/agent-prompt-opencode-resubmit-runtime.test.ts b/src/main/runtime/agent-prompt-opencode-resubmit-runtime.test.ts deleted file mode 100644 index 1de5832f1a93..000000000000 --- a/src/main/runtime/agent-prompt-opencode-resubmit-runtime.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { AGENT_PROMPT_TEST_WORKTREE_PATH } from './agent-prompt-submission-runtime-test-fixture' -import { OrcaRuntimeService } from './orca-runtime' -import { makeStore } from './runtime-rpc-worktree-store-fixtures' -import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' - -vi.mock('../git/worktree', () => ({ - listWorktrees: vi.fn().mockResolvedValue([ - { - path: '/tmp/worktree-a', - head: 'abc', - branch: 'feature/prompt-verification', - isBare: false, - isMainWorktree: false - } - ]), - listWorktreesStrict: vi.fn().mockResolvedValue([ - { - path: '/tmp/worktree-a', - head: 'abc', - branch: 'feature/prompt-verification', - isBare: false, - isMainWorktree: false - } - ]) -})) - -const POLICY = TUI_AGENT_CONFIG.opencode.submitRetryUntilTurnStart! -const DISPATCH_OPTIONS = { - inputKind: 'driving' as const, - acceptQueued: true, - requestId: 'dispatch-1', - observationTimeoutMs: 0 -} - -type Hook = { state: 'done' | 'working' | 'waiting'; stateStartedAt: number } | null - -/** - * An OpenCode pane that drops every Enter before `honoredFromEnter`, the way OpenCode 2 drops one - * sent before its agent list loads; the honoured Enter starts a turn its status hook reports. - */ -async function createOpenCodePane(args: { - honoredFromEnter: number - hook: { current: Hook } - hooksEnabled?: boolean -}) { - let handle = '' - const writes: string[] = [] - const baseStore = makeStore() - const store = { - ...baseStore, - getSettings: () => ({ - ...baseStore.getSettings(), - ...(args.hooksEnabled === false ? { agentStatusHooksEnabled: false } : {}) - }) - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared worktree-store fixture covers every store call this runtime makes. - const runtime = new OrcaRuntimeService(store as never, undefined, { - getAgentStatusSnapshot: () => - args.hook.current - ? [ - { - paneKey: 'prompt-pane', - terminalHandle: handle, - state: args.hook.current.state, - prompt: '', - agentType: 'opencode', - connectionId: null, - receivedAt: Date.now(), - stateStartedAt: args.hook.current.stateStartedAt - } - ] - : [] - }) - runtime.setPtyController({ - spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }), - write: (_ptyId, data) => { - writes.push(data) - if (data === '\r' && writes.filter((w) => w === '\r').length >= args.honoredFromEnter) { - args.hook.current = { state: 'working', stateStartedAt: Date.now() } - } - return true - }, - kill: () => true, - getForegroundProcess: async () => null - }) - handle = ( - await runtime.createTerminal(`path:${AGENT_PROMPT_TEST_WORKTREE_PATH}`, { - launchAgent: 'opencode' - }) - ).handle - return { runtime, handle, writes, enters: () => writes.filter((w) => w === '\r').length } -} - -describe('OpenCode prompt submit keeps pressing Enter until its turn starts', () => { - afterEach(() => vi.useRealTimers()) - - it('observes the turn after the Enter OpenCode finally honours', async () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - const hook: { current: Hook } = { current: { state: 'done', stateStartedAt: 500 } } - const pane = await createOpenCodePane({ honoredFromEnter: 3, hook }) - - const sent = sendBrief(pane) - await vi.runAllTimersAsync() - const receipt = (await sent).prompt! - expect(receipt).toMatchObject({ - provider: 'opencode', - observation: 'supported', - stages: ['input_accepted'] - }) - expect(pane.enters()).toBe(1) - - const observed = pane.runtime.observeTerminalAgentPrompt(pane.handle, receipt, 30_000) - await vi.advanceTimersByTimeAsync(POLICY.intervalMs * 2 + 200) - - await expect(observed).resolves.toMatchObject({ - stages: ['input_accepted', 'turn_started'], - observation: 'supported' - }) - expect(pane.enters()).toBe(3) - }) - - it('sends no extra Enter and reports the prompt when an approval is showing', async () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - const hook: { current: Hook } = { current: { state: 'done', stateStartedAt: 500 } } - const pane = await createOpenCodePane({ honoredFromEnter: 99, hook }) - - const sent = sendBrief(pane) - await vi.runAllTimersAsync() - const observed = pane.runtime.observeTerminalAgentPrompt( - pane.handle, - (await sent).prompt!, - 30_000 - ) - hook.current = { state: 'waiting', stateStartedAt: Date.now() } - await vi.advanceTimersByTimeAsync(POLICY.intervalMs * 3) - - await expect(observed).resolves.toMatchObject({ observation: 'permission' }) - expect(pane.enters()).toBe(1) - }) - - it("keeps today's unobserved receipt and single Enter when status hooks are off", async () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - const pane = await createOpenCodePane({ - honoredFromEnter: 2, - hook: { current: null }, - hooksEnabled: false - }) - - const sent = sendBrief(pane) - await vi.runAllTimersAsync() - - expect((await sent).prompt).toMatchObject({ - provider: 'unsupported', - observation: 'unsupported' - }) - expect(pane.enters()).toBe(1) - }) - - it('never presses Enter blind and reports no observation when the pane never reports a hook', async () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - const pane = await createOpenCodePane({ honoredFromEnter: 2, hook: { current: null } }) - - const sent = sendBrief(pane) - await vi.runAllTimersAsync() - const observed = pane.runtime.observeTerminalAgentPrompt( - pane.handle, - (await sent).prompt!, - 10_000 - ) - await vi.advanceTimersByTimeAsync(10_200) - - await expect(observed).resolves.toMatchObject({ - stages: ['input_accepted'], - observation: 'unsupported' - }) - expect(pane.enters()).toBe(1) - }) - - it("stops after the row's retry cap and leaves the turn honestly unobserved", async () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - const hook: { current: Hook } = { current: { state: 'done', stateStartedAt: 500 } } - const pane = await createOpenCodePane({ honoredFromEnter: 99, hook }) - - const sent = sendBrief(pane) - await vi.runAllTimersAsync() - const observed = pane.runtime.observeTerminalAgentPrompt( - pane.handle, - (await sent).prompt!, - 30_000 - ) - await vi.advanceTimersByTimeAsync(30_200) - - await expect(observed).resolves.toMatchObject({ - stages: ['input_accepted'], - observation: 'supported' - }) - expect(pane.enters()).toBe(1 + POLICY.maxRetries) - }) -}) - -function sendBrief(pane: Awaited>) { - return pane.runtime.sendTerminalAgentPrompt(pane.handle, 'the brief', DISPATCH_OPTIONS) -} diff --git a/src/main/runtime/agent-prompt-resubmit.test.ts b/src/main/runtime/agent-prompt-resubmit.test.ts deleted file mode 100644 index 408bc722d82e..000000000000 --- a/src/main/runtime/agent-prompt-resubmit.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { createAgentPromptResubmit } from './agent-prompt-resubmit' -import { - resolveTerminalPromptSettlementAgent, - verifyAgentPromptSubmission, - type AgentPromptActivity -} from './agent-prompt-submission-verification' -import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' - -const POLICY = TUI_AGENT_CONFIG.opencode.submitRetryUntilTurnStart! - -function activity(overrides: Partial = {}): AgentPromptActivity { - return { - generation: 1, - permissionSequence: 0, - workingSequence: 0, - explicitWorkingStartedAt: null, - outputSequence: 0, - status: 'idle', - ...overrides - } -} - -/** A pane whose turn starts on the Nth Enter (the first Enter was the original submit). */ -function pane(turnStartsOnEnter: number) { - const state = { - current: activity(), - enters: 1, - hooksReporting: true, - blockWrites: false - } - const writeEnter = vi.fn(() => { - state.enters += 1 - if (state.enters >= turnStartsOnEnter) { - state.current = activity({ explicitWorkingStartedAt: 1_000, status: 'working' }) - } - return true - }) - const resubmit = createAgentPromptResubmit({ - agent: 'opencode', - hooksReporting: () => state.hooksReporting, - assertSafe: () => { - if (state.current.status === 'permission') { - throw new Error('agent_prompt_blocked') - } - }, - writeEnter - }) - return { state, writeEnter, resubmit } -} - -function verify(p: ReturnType, timeoutMs = 30_000) { - return verifyAgentPromptSubmission({ - baseline: activity(), - readActivity: () => p.state.current, - allowOutputEvidence: false, - timeoutMs, - resubmit: p.resubmit - }) -} - -describe('agent prompt resubmit', () => { - afterEach(() => vi.useRealTimers()) - - it('is configured only for agents whose row asks for it', () => { - const noop = { hooksReporting: () => true, assertSafe: () => {}, writeEnter: () => true } - expect(createAgentPromptResubmit({ agent: 'opencode', ...noop })).toBeDefined() - expect(createAgentPromptResubmit({ agent: 'opencode2', ...noop })).toBeDefined() - for (const agent of ['claude', 'codex', 'antigravity', 'grok'] as const) { - expect(createAgentPromptResubmit({ agent, ...noop })).toBeUndefined() - } - expect(createAgentPromptResubmit({ agent: null, ...noop })).toBeUndefined() - }) - - it('sends nothing when the first Enter started the turn', async () => { - vi.useFakeTimers() - const p = pane(1) - p.state.current = activity({ explicitWorkingStartedAt: 1_000, status: 'working' }) - - await expect(verify(p)).resolves.toBeUndefined() - expect(p.writeEnter).not.toHaveBeenCalled() - }) - - it.each([2, 3, POLICY.maxRetries + 1])( - 'keeps pressing Enter on the interval until the turn starts on Enter %i', - async (enter) => { - vi.useFakeTimers() - const p = pane(enter) - const verification = verify(p) - - await vi.advanceTimersByTimeAsync(POLICY.intervalMs * (enter - 1) + 100) - - await expect(verification).resolves.toBeUndefined() - expect(p.writeEnter).toHaveBeenCalledTimes(enter - 1) - } - ) - - it('waits a full interval before the first extra Enter', async () => { - vi.useFakeTimers() - const p = pane(99) - void verify(p).catch(() => {}) - - await vi.advanceTimersByTimeAsync(POLICY.intervalMs - 100) - expect(p.writeEnter).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(200) - expect(p.writeEnter).toHaveBeenCalledTimes(1) - }) - - it('stops at maxRetries and reports the turn as unobserved once the budget ends', async () => { - vi.useFakeTimers() - const p = pane(99) - const verification = verify(p, 30_000) - const settled = expect(verification).rejects.toThrow('agent_prompt_stalled') - - await vi.advanceTimersByTimeAsync(30_100) - - await settled - expect(p.writeEnter).toHaveBeenCalledTimes(POLICY.maxRetries) - }) - - it('never presses Enter while the pane has no hook status, so a hidden prompt is never answered', async () => { - vi.useFakeTimers() - const p = pane(2) - p.state.hooksReporting = false - const verification = verify(p, 10_000) - const settled = expect(verification).rejects.toThrow('agent_prompt_stalled') - - await vi.advanceTimersByTimeAsync(10_100) - - await settled - expect(p.writeEnter).not.toHaveBeenCalled() - }) - - it('resumes once hooks start reporting mid-wait', async () => { - vi.useFakeTimers() - const p = pane(2) - p.state.hooksReporting = false - const verification = verify(p) - - await vi.advanceTimersByTimeAsync(POLICY.intervalMs * 2) - expect(p.writeEnter).not.toHaveBeenCalled() - p.state.hooksReporting = true - await vi.advanceTimersByTimeAsync(POLICY.intervalMs + 100) - - await expect(verification).resolves.toBeUndefined() - expect(p.writeEnter).toHaveBeenCalledTimes(1) - }) - - it('stops without another Enter when a permission prompt appears mid-wait', async () => { - vi.useFakeTimers() - const p = pane(99) - const verification = verify(p) - const settled = expect(verification).rejects.toThrow('agent_prompt_blocked') - - await vi.advanceTimersByTimeAsync(POLICY.intervalMs + 100) - expect(p.writeEnter).toHaveBeenCalledTimes(1) - p.state.current = activity({ status: 'permission' }) - await vi.advanceTimersByTimeAsync(POLICY.intervalMs * 3) - - await settled - expect(p.writeEnter).toHaveBeenCalledTimes(1) - }) - - it('refuses the Enter itself when a prompt appeared between the check and the write', async () => { - vi.useFakeTimers() - const writeEnter = vi.fn(() => true) - const resubmit = createAgentPromptResubmit({ - agent: 'opencode', - hooksReporting: () => true, - assertSafe: () => { - throw new Error('agent_prompt_blocked') - }, - writeEnter - })! - - await expect(resubmit.send()).rejects.toThrow('agent_prompt_blocked') - expect(writeEnter).not.toHaveBeenCalled() - }) - - it('stops without another Enter when the pane is replaced mid-wait', async () => { - vi.useFakeTimers() - const p = pane(99) - const verification = verify(p) - const settled = expect(verification).rejects.toThrow('terminal_handle_stale') - - await vi.advanceTimersByTimeAsync(POLICY.intervalMs + 100) - p.state.current = activity({ generation: 2 }) - await vi.advanceTimersByTimeAsync(POLICY.intervalMs * 3) - - await settled - expect(p.writeEnter).toHaveBeenCalledTimes(1) - }) - - it('routes each Enter through the caller serialisation when given one', async () => { - const order: string[] = [] - const resubmit = createAgentPromptResubmit({ - agent: 'opencode2', - hooksReporting: () => true, - assertSafe: () => order.push('check'), - writeEnter: () => { - order.push('write') - return true - }, - serialize: async (send) => { - order.push('lock') - return await send() - } - })! - - await expect(resubmit.send()).resolves.toBe(true) - expect(order).toEqual(['lock', 'check', 'write']) - }) -}) - -describe('resolveTerminalPromptSettlementAgent', () => { - const hooksOn = (): boolean => true - const hooksOff = (): boolean => false - - it('keeps the hook-and-title providers regardless of the hooks setting', () => { - expect(resolveTerminalPromptSettlementAgent('claude', undefined, hooksOff)).toBe('claude') - expect(resolveTerminalPromptSettlementAgent(undefined, 'codex', hooksOff)).toBe('codex') - expect(resolveTerminalPromptSettlementAgent('antigravity', null, hooksOff)).toBe('antigravity') - }) - - it('observes a retrying agent only while its status hooks are enabled', () => { - expect(resolveTerminalPromptSettlementAgent('opencode', undefined, hooksOn)).toBe('opencode') - expect(resolveTerminalPromptSettlementAgent(undefined, 'opencode2', hooksOn)).toBe('opencode2') - expect(resolveTerminalPromptSettlementAgent('opencode', undefined, hooksOff)).toBeNull() - }) - - it('prefers an established provider over a retrying one and ignores other agents', () => { - expect(resolveTerminalPromptSettlementAgent('opencode', 'codex', hooksOn)).toBe('codex') - expect(resolveTerminalPromptSettlementAgent('grok', 'cursor', hooksOn)).toBeNull() - }) -}) diff --git a/src/main/runtime/agent-prompt-resubmit.ts b/src/main/runtime/agent-prompt-resubmit.ts deleted file mode 100644 index 793b462a1d4d..000000000000 --- a/src/main/runtime/agent-prompt-resubmit.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { TerminalAgent } from '../../shared/terminal-agent' -import { isTuiAgent, TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' - -/** - * Extra Enters a prompt verifier may press while no turn start is observed, for TUIs that draw - * their composer before Enter is live (OpenCode 2 drops an Enter until its agent list loads, and - * the bracketed paste then sits in the composer unsent). - */ -export type AgentPromptResubmit = { - intervalMs: number - maxRetries: number - /** One Enter when it is safe; false when it held back for lack of hook evidence. */ - send: () => Promise -} - -export function getAgentSubmitRetryUntilTurnStart( - agent: TerminalAgent | null | undefined -): { intervalMs: number; maxRetries: number } | undefined { - return isTuiAgent(agent) ? TUI_AGENT_CONFIG[agent].submitRetryUntilTurnStart : undefined -} - -export function createAgentPromptResubmit(args: { - agent: TerminalAgent | null | undefined - /** Whether this pane's status hooks are reporting right now. */ - hooksReporting: () => boolean - /** Throws when the pane was replaced or an approval or question prompt is showing. */ - assertSafe: () => void - writeEnter: () => boolean - /** Runs the send under the pane's prompt serialisation when the caller does not hold it. */ - serialize?: (send: () => Promise) => Promise -}): AgentPromptResubmit | undefined { - const policy = getAgentSubmitRetryUntilTurnStart(args.agent) - if (!policy) { - return undefined - } - const sendNow = async (): Promise => { - // Why: without hooks Orca cannot see an approval or question the Enter would answer. - if (!args.hooksReporting()) { - return false - } - args.assertSafe() - return args.writeEnter() - } - return { - intervalMs: policy.intervalMs, - maxRetries: policy.maxRetries, - send: () => (args.serialize ? args.serialize(sendNow) : sendNow()) - } -} diff --git a/src/main/runtime/agent-prompt-submission-verification.ts b/src/main/runtime/agent-prompt-submission-verification.ts index 0659316eee03..91da0ed9dfe9 100644 --- a/src/main/runtime/agent-prompt-submission-verification.ts +++ b/src/main/runtime/agent-prompt-submission-verification.ts @@ -1,11 +1,6 @@ export { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets' import { AGENT_PROMPT_EFFECT_TIMEOUT_MS } from '../../shared/orchestration-timing-budgets' -import type { TerminalAgent, TuiAgent } from '../../shared/terminal-agent' -import { - getAgentSubmitRetryUntilTurnStart, - type AgentPromptResubmit -} from './agent-prompt-resubmit' -import { isTuiAgent } from '../../shared/tui-agent-config' +import type { TerminalAgent } from '../../shared/terminal-agent' export const AGENT_PROMPT_HOOK_EFFECT_TIMEOUT_MS = AGENT_PROMPT_EFFECT_TIMEOUT_MS const AGENT_PROMPT_EFFECT_POLL_MS = 50 @@ -47,8 +42,6 @@ type AgentPromptVerificationOptions = { allowOutputEvidence?: boolean signal?: AbortSignal timeoutMs?: number - /** Presses Enter again while no turn start is observed (see agent-prompt-resubmit.ts). */ - resubmit?: AgentPromptResubmit } export function resolveAgentPromptEffectTimeoutMs(agent: TerminalAgent | null | undefined): number { @@ -64,32 +57,6 @@ export function isTerminalSendSettlementAgent( return agent === 'antigravity' || agent === 'claude' || agent === 'codex' } -/** - * The provider whose turn start settles this prompt's receipt. Agents whose row retries Enter until - * a turn starts are observed through their status hooks, so they qualify only while those are on. - */ -export function resolveTerminalPromptSettlementAgent( - foregroundAgent: TerminalAgent | null | undefined, - launchAgent: TuiAgent | null | undefined, - isStatusHooksEnabled: (agent: TuiAgent) => boolean -): TuiAgent | null { - for (const agent of [foregroundAgent, launchAgent]) { - if (isTerminalSendSettlementAgent(agent)) { - return agent - } - } - for (const agent of [foregroundAgent, launchAgent]) { - if ( - isTuiAgent(agent) && - getAgentSubmitRetryUntilTurnStart(agent) && - isStatusHooksEnabled(agent) - ) { - return agent - } - } - return null -} - export function isAgentPromptStalledError(error: unknown): boolean { if (error instanceof Error && error.message === AGENT_PROMPT_STALLED_ERROR) { return true @@ -123,9 +90,6 @@ export async function verifyAgentPromptSubmission( assertPromptNotBlocked(options.baseline, options.baseline) const deadline = Date.now() + (options.timeoutMs ?? AGENT_PROMPT_EFFECT_TIMEOUT_MS) - const { resubmit } = options - let resubmits = 0 - let lastSubmitAt = Date.now() while (Date.now() < deadline) { const current = options.readActivity() assertSamePromptGeneration(options.baseline, current) @@ -141,19 +105,6 @@ export async function verifyAgentPromptSubmission( ) { return } - // Why after the checks above: a changed pane or a drawn prompt has already thrown, so no - // extra Enter can land on a replaced process or answer an approval. - if ( - resubmit && - resubmits < resubmit.maxRetries && - Date.now() - lastSubmitAt >= resubmit.intervalMs - ) { - lastSubmitAt = Date.now() - if (await resubmit.send()) { - resubmits += 1 - } - continue - } await waitForAgentPromptPoll(options.signal) } diff --git a/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts index 8fbbf19d552d..f4cc73e14d31 100644 --- a/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts +++ b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts @@ -17,29 +17,34 @@ import type { GrokStartupTraceChunk } from '../../shared/__fixtures__/grok-start import { readRuntimeFixture, readTimedRuntimeFixture } from './agent-transcript-replay-test-harness' type Walk = { anchor: string; end: string; marker: string } +type Walks = Walk | Walk[] 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' - }, +const BOX_CURSOR: Walk = { anchor: '\x1b[?2004h', end: '\x1b[?2004l', marker: '\x1b[?25h' } +// A signal with several walks is ready once all of them are. +const WALKS: Partial> = { + 'render-cursor-after-bracketed-paste': BOX_CURSOR, 'grok-composer-prompt': { ...ALT_SCREEN, marker: '❯' }, 'dsh-composer-prompt': { ...ALT_SCREEN, marker: '❯' }, - 'zcode-composer-prompt': { ...ALT_SCREEN, marker: '╭' } + 'zcode-composer-prompt': { ...ALT_SCREEN, marker: '╭' }, + 'opencode-agent-row': [BOX_CURSOR, { ...ALT_SCREEN, marker: '\u00b7' }] } // 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. +// The launcher's cursor toggle after that is synthetic, standing in for any spinner, and so is the +// `·` in the prompt, standing in for a theme that draws OpenCode's agent-row separator. const ZSH_LAUNCH_PROLOGUE = - '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' + '\x1b[?2004h~ \u00b7 main % 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' + 'opencode-2-0-18-timed-boot-hidden-pane', + 'opencode-2-0-21-timed-cold-standalone', + 'opencode-2-0-21-timed-cold-standalone-hidden-pane', + 'opencode-2-0-21-timed-busy-standalone', + 'opencode-cmd-2-0-21-timed-warm-server' ] type Case = { name: string; signal: DraftPasteReadySignal; data: string; reads?: string[] } @@ -70,17 +75,22 @@ const CASES: Case[] = [ data: readRuntimeFixture('zcode-composer-ready') }, { - name: 'opencode (untimed pty transcript)', + name: 'opencode (untimed pty transcript), cursor signal mimo-code still uses', signal: 'render-cursor-after-bracketed-paste', data: readPtyTranscript('opencode.txt') }, + { + name: 'opencode (untimed pty transcript)', + signal: 'opencode-agent-row', + 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, signal: 'opencode-agent-row', data: chunks.join(''), reads: chunks }, { name: `${name} behind a zsh launch`, - signal: 'render-cursor-after-bracketed-paste', + signal: 'opencode-agent-row', data: ZSH_LAUNCH_PROLOGUE + chunks.join(''), reads: [ZSH_LAUNCH_PROLOGUE, ...chunks] } @@ -88,8 +98,14 @@ const CASES: Case[] = [ }) ] +/** End offset where every walk has seen its marker, or null when one never does. */ +function walkReadyEnd(data: string, walks: Walks): number | null { + const ends = (Array.isArray(walks) ? walks : [walks]).map((walk) => walkOne(data, walk)) + return ends.some((end) => end === null) ? null : Math.max(...ends.map((end) => end ?? 0)) +} + /** 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 { +function walkOne(data: string, { anchor, end, marker }: Walk): number | null { let cursor = 0 for (;;) { const enter = data.indexOf(anchor, cursor) @@ -182,7 +198,7 @@ describe('draft-paste readiness does not depend on how the stream is chunked', ( }) 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 walk = WALKS['opencode-agent-row']! const data = readTimedRuntimeFixture(name).chunks.join('') const alone = walkReadyEnd(data, walk) expect(alone).not.toBeNull() diff --git a/src/main/runtime/launched-agent-composer-readiness.test.ts b/src/main/runtime/launched-agent-composer-readiness.test.ts index 3dc20ac120ed..6baac6d9be77 100644 --- a/src/main/runtime/launched-agent-composer-readiness.test.ts +++ b/src/main/runtime/launched-agent-composer-readiness.test.ts @@ -69,7 +69,7 @@ const EXPECTED_LANES: Record = { const FIXTURES = join(__dirname, '__fixtures__') const OPENCODE_PLACEHOLDER = 'Ask anything' -const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' +const AGENT_ROW = /\u00b7 \S/ const CITED_CAPTURES = Object.entries(TUI_AGENT_CONFIG).flatMap(([agent, row]) => (row.composerReadyCaptures ?? []).map((capture) => [agent, capture] as const) @@ -138,35 +138,34 @@ describe('every capture a row cites proves its input-box marker', () => { 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', + it.each(CITED_CAPTURES.filter(([agent]) => readSignal(agent) === 'opencode-agent-row'))( + '%s: the first ready read in %s is no earlier than the agent row under the 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 rowRead = -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 + const box = frame.screenLines.findIndex((line) => line.includes(OPENCODE_PLACEHOLDER)) + if (box !== -1 && boxRead === -1) { + boxRead = read + } + // The row under the box reads ` · `; a read can paint the separator first. + if (box !== -1 && frame.screenLines.slice(box + 1).some((line) => AGENT_ROW.test(line))) { + rowRead = read break } read += 1 } - expect(placeholderRead).toBeGreaterThanOrEqual(0) - expect(boxRead).toBeGreaterThanOrEqual(placeholderRead) - expect(firstReadyRead(agent, chunks)).toBe(boxRead) + expect(boxRead).toBeGreaterThanOrEqual(0) + expect(rowRead).toBeGreaterThanOrEqual(boxRead) + // OpenCode 1 paints the row just before the box's cursor in one frame; 2 paints it later. + const cursorRead = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') + const boxCursorRead = chunks.findIndex((chunk) => cursorRead.observe(chunk).ready) + expect(firstReadyRead(agent, chunks)).toBe(Math.max(rowRead, boxCursorRead)) } ) }) diff --git a/src/main/runtime/opencode-agent-row-enter-transcript.test.ts b/src/main/runtime/opencode-agent-row-enter-transcript.test.ts new file mode 100644 index 000000000000..f14958603a60 --- /dev/null +++ b/src/main/runtime/opencode-agent-row-enter-transcript.test.ts @@ -0,0 +1,53 @@ +/** + * Proof that the agent-row signal is the moment OpenCode 2 can submit. Both captures are cold + * `--standalone` starts launched four at a time; each pasted the same brief and pressed Enter + * (promptSentAtMs) — one once the agent row was painted, one at the box's show-cursor before it. + */ + +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 { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { readTimedRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness' + +const HELD_PASTE = '[Pasted ~' +const BRIEF_TASK_LINE = 'Task: reply with the single word' + +async function replayEnter(name: string) { + const fixture = readTimedRuntimeFixture(name) + const meta: { cols: number; rows: number } = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', `${name}.meta.json`), 'utf8') + ) + const scanner = createDraftPasteReadyScanner(TUI_AGENT_CONFIG.opencode2.draftPasteReadySignal!) + const readyRead = fixture.chunks.findIndex((chunk) => scanner.observe(chunk).ready) + let lastScreen: string[] = [] + for await (const frame of replayTranscript(fixture.chunks, meta.cols, meta.rows)) { + lastScreen = frame.screenLines + } + return { + readyAtMs: fixture.times[readyRead], + enterAtMs: fixture.promptSentAtMs!, + lastScreen: lastScreen.join('\n') + } +} + +describe('an Enter OpenCode 2 receives after the agent-row signal submits', () => { + it('submits the brief when Enter follows the signal', async () => { + const { readyAtMs, enterAtMs, lastScreen } = await replayEnter( + 'opencode-2-0-21-timed-enter-at-agent-row' + ) + expect(readyAtMs).toBeLessThanOrEqual(enterAtMs) + expect(lastScreen).not.toContain(HELD_PASTE) + expect(lastScreen).toContain(BRIEF_TASK_LINE) + }) + + it('leaves the brief in the composer when Enter came at the box, before the signal', async () => { + const { readyAtMs, enterAtMs, lastScreen } = await replayEnter( + 'opencode-2-0-21-timed-enter-at-box' + ) + expect(enterAtMs).toBeLessThan(readyAtMs) + expect(lastScreen).toContain(HELD_PASTE) + expect(lastScreen).not.toContain(BRIEF_TASK_LINE) + }) +}) diff --git a/src/main/runtime/opencode-worker-composer-transcript.test.ts b/src/main/runtime/opencode-worker-composer-transcript.test.ts index 52aa2f4da86e..844e99b99000 100644 --- a/src/main/runtime/opencode-worker-composer-transcript.test.ts +++ b/src/main/runtime/opencode-worker-composer-transcript.test.ts @@ -28,8 +28,14 @@ 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'] + ['opencode-cmd-2-0-21-timed-warm-server', 'opencode'], + ['opencode-2-0-18-timed-boot-hidden-pane', 'opencode2'], + ['opencode-2-0-21-timed-cold-standalone', 'opencode2'], + ['opencode-2-0-21-timed-cold-standalone-hidden-pane', 'opencode2'], + ['opencode-2-0-21-timed-busy-standalone', 'opencode2'] ] +const OPENCODE_1_RUNS = RUNS.filter(([name]) => name.startsWith('opencode-1-')) +const AGENT_ROW_SEPARATOR = '\u00b7' /** The read that ends the synchronized update drawing OpenCode's input box. */ function boxRead(chunks: string[]): number { @@ -41,6 +47,14 @@ function boxRead(chunks: string[]): number { return chunks.findIndex((chunk) => (end += chunk.length) >= boxEnd) } +/** The read that paints the separator in the agent/model row under the box. */ +function agentRowRead(chunks: string[]): number { + const data = chunks.join('') + const separator = data.indexOf(AGENT_ROW_SEPARATOR, data.indexOf('\x1b[?1049h')) + let end = 0 + return chunks.findIndex((chunk) => (end += chunk.length) > separator) +} + async function replay(name: string, agent: TuiAgent) { const { chunks, times } = readTimedRuntimeFixture(name) const { runtime, handle } = await createTranscriptPane({ @@ -73,23 +87,40 @@ async function replay(name: string, agent: TuiAgent) { runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) await vi.advanceTimersByTimeAsync(0) } - return { settledAt, composer, boxRead: boxRead(chunks) } + return { settledAt, composer, boxRead: boxRead(chunks), agentRowRead: agentRowRead(chunks) } } -describe('an OpenCode worker gets its task only once its input box exists', () => { +describe('an OpenCode worker gets its task only once OpenCode can submit it', () => { afterEach(() => { vi.useRealTimers() }) it.each(RUNS)( - '%s: the worker-start lane settles on the read that shows the box', + '%s: the worker-start lane settles once the box and the agent row are both painted', async (name, agent) => { - const { settledAt, composer, boxRead } = await replay(name, agent) + const { settledAt, composer, boxRead, agentRowRead } = await replay(name, agent) await expect(composer).resolves.toMatchObject({ satisfied: true }) + // OpenCode 1 paints the row inside the box's frame, so the box read is the later one there. + expect(settledAt.composer).toBe(Math.max(boxRead, agentRowRead)) + } + ) + + it.each(OPENCODE_1_RUNS)( + '%s: OpenCode 1 paints the agent row with the box, so it waits no longer than before', + async (name, agent) => { + const { settledAt, boxRead } = await replay(name, agent) expect(settledAt.composer).toBe(boxRead) } ) + it('never settles on the box while a slow agent list leaves the row unpainted', async () => { + const { settledAt, boxRead } = await replay( + 'opencode-2-0-21-timed-busy-standalone', + 'opencode2' + ) + expect(settledAt.composer).toBeGreaterThan(boxRead) + }) + it.each(RUNS)( '%s: the bare-name tui-idle wait main used would have settled before the box', async (name, agent) => { diff --git a/src/main/runtime/orca-runtime-agent-prompt-request-correlation.ts b/src/main/runtime/orca-runtime-agent-prompt-request-correlation.ts index edbd8ae04999..9606d33e7dbf 100644 --- a/src/main/runtime/orca-runtime-agent-prompt-request-correlation.ts +++ b/src/main/runtime/orca-runtime-agent-prompt-request-correlation.ts @@ -3,19 +3,11 @@ import type { RuntimeTerminalPromptDelivery } from '../../shared/runtime-types' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { TerminalHandleRecord } from './runtime-terminal-contracts' import type { - AgentPromptActivity, AgentPromptTurnStartEvidence, AgentPromptWaitTextCache } from './agent-prompt-submission-verification' import { verifyAgentPromptSubmission } from './agent-prompt-submission-verification' import { AgentPromptRequestCorrelation } from './agent-prompt-request-correlation' -import { - createAgentPromptResubmit, - getAgentSubmitRetryUntilTurnStart, - type AgentPromptResubmit -} from './agent-prompt-resubmit' -import { AGENT_PROMPT_SUBMIT } from '../../shared/agent-prompt-injection' -import type { TuiAgent } from '../../shared/tui-agent' export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWithSerializeAgentPromptSubmission { private readonly agentPromptCorrelation = new AgentPromptRequestCorrelation() @@ -27,11 +19,6 @@ export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWit record: TerminalHandleRecord leaf: RuntimeLeafRecord } - declare protected assertAgentPromptGeneration: (ptyId: string, expected: number) => void - declare protected assertAgentPromptPermissionSafe: ( - baseline: AgentPromptActivity, - current: AgentPromptActivity - ) => void getTerminalPromptRequestBinding(handle: string): { ptyId: string @@ -66,21 +53,19 @@ export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWit return { ...prompt, observation: 'incarnation_replaced' } } const waitTextCache: AgentPromptWaitTextCache = {} - const current = this.getAgentPromptActivity(handle, binding.ptyId, waitTextCache) - const baseline = { - ...current, - workingSequence: prompt.baselineWorkingSequence, - ...(prompt.baselinePermissionSequence !== undefined - ? { permissionSequence: prompt.baselinePermissionSequence } - : {}), - ...(prompt.baselineExplicitWorkingStartedAt !== undefined - ? { explicitWorkingStartedAt: prompt.baselineExplicitWorkingStartedAt } - : {}) - } - const resubmitAgent = isTuiAgentProvider(prompt.provider) ? prompt.provider : null + const baseline = this.getAgentPromptActivity(handle, binding.ptyId, waitTextCache) try { await verifyAgentPromptSubmission({ - baseline, + baseline: { + ...baseline, + workingSequence: prompt.baselineWorkingSequence, + ...(prompt.baselinePermissionSequence !== undefined + ? { permissionSequence: prompt.baselinePermissionSequence } + : {}), + ...(prompt.baselineExplicitWorkingStartedAt !== undefined + ? { explicitWorkingStartedAt: prompt.baselineExplicitWorkingStartedAt } + : {}) + }, readActivity: () => this.getAgentPromptActivity(handle, binding.ptyId, waitTextCache), acceptTurnStart: (evidence) => this.acceptAgentPromptTurnStart( @@ -95,19 +80,13 @@ export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWit allowHookEvidence: prompt.baselineExplicitWorkingStartedAt !== undefined, allowOutputEvidence: false, signal, - timeoutMs, - resubmit: this.createObservedPromptResubmit(handle, binding, baseline, resubmitAgent) + timeoutMs }) this.forgetAgentPromptRequest(binding.ptyId, binding.generation, prompt.requestId) return { ...prompt, stages: ['input_accepted', 'turn_started'], observation: 'supported' } } catch (error) { if (error instanceof Error && error.message === 'agent_prompt_stalled') { - // Why: such a provider is observed only through its status hooks; a pane that never - // reported cannot show a turn, so its receipt must not read as observable-but-missing. - return getAgentSubmitRetryUntilTurnStart(resubmitAgent) && - this.getFreshExplicitAgentStatusForPty(handle, binding.ptyId) === null - ? { ...prompt, observation: 'unsupported' } - : prompt + return prompt } if (error instanceof Error && error.message === 'agent_prompt_blocked') { this.forgetAgentPromptRequest(binding.ptyId, binding.generation, prompt.requestId) @@ -117,28 +96,6 @@ export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWit } } - /** Extra Enters for a prompt observed after its write, under the pane's prompt serialisation. */ - private createObservedPromptResubmit( - handle: string, - binding: { ptyId: string; generation: number }, - baseline: AgentPromptActivity, - agent: TuiAgent | null - ): AgentPromptResubmit | undefined { - const { ptyId, generation } = binding - return createAgentPromptResubmit({ - agent, - hooksReporting: () => this.getFreshExplicitAgentStatusForPty(handle, ptyId) !== null, - assertSafe: () => { - this.assertAgentPromptGeneration(ptyId, generation) - this.assertAgentPromptPermissionSafe(baseline, this.getAgentPromptActivity(handle, ptyId)) - }, - writeEnter: () => this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, 'driving') === true, - // Why: another prompt's paste may be mid-flight; an Enter between its paste and submit - // would send it early. - serialize: (send) => this.serializeAgentPromptSubmission(ptyId, generation, send) - }) - } - protected registerAgentPromptRequest( ptyId: string, generation: number, @@ -180,9 +137,3 @@ export class OrcaRuntimeWithAgentPromptRequestCorrelation extends OrcaRuntimeWit this.agentPromptCorrelation.clearForPty(ptyId) } } - -function isTuiAgentProvider( - provider: RuntimeTerminalPromptDelivery['provider'] -): provider is TuiAgent { - return provider !== 'unsupported' && provider !== 'old-host' -} diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts index 5a5c95214031..d83bcfb8511b 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts @@ -554,7 +554,12 @@ describe('OrcaRuntimeService', () => { // Past the old 8s default, where readiness would previously have been abandoned. await vi.advanceTimersByTimeAsync(10_000) - runtime.onPtyData('pty-opencode-draft-budget', '\x1b[?2004h\x1b[?25h', Date.now()) + // The box and its cursor, then the agent/model row once OpenCode's agent list has loaded. + runtime.onPtyData( + 'pty-opencode-draft-budget', + '\x1b[?1049h\x1b[?2004h\x1b[?25hBuild \u00b7 Some Model', + Date.now() + ) await Promise.resolve() await Promise.resolve() 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 6aabdbe4fb4e..1597084eee15 100644 --- a/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts +++ b/src/main/runtime/orca-runtime-write-terminal-agent-prompt.ts @@ -16,12 +16,10 @@ import { } from '../../shared/agent-prompt-injection' import type { AgentPromptWaitTextCache } from './agent-prompt-submission-verification' import { + isTerminalSendSettlementAgent, resolveAgentPromptEffectTimeoutMs, - resolveTerminalPromptSettlementAgent, verifyAgentPromptSubmission } from './agent-prompt-submission-verification' -import { createAgentPromptResubmit } from './agent-prompt-resubmit' -import { isAgentStatusHooksEnabledForAgent } from '../../shared/agent-status-hooks-setting' export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission { protected async writeTerminalAgentPrompt( @@ -110,45 +108,23 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso } } const effectTimeoutMs = resolveAgentPromptEffectTimeoutMs(this.getPtyAgent(ptyId)) - const foregroundAgent = this.ptysById.get(ptyId)?.foregroundAgent - const launchAgent = this.ptysById.get(ptyId)?.launchAgent - let extraSubmits = 0 - // Already inside this pane's prompt serialisation, so the extra Enter writes directly. - const resubmit = submitWithPaste - ? undefined - : createAgentPromptResubmit({ - agent: foregroundAgent ?? launchAgent, - hooksReporting: () => this.getFreshExplicitAgentStatusForPty(handle, ptyId) !== null, - assertSafe: () => { - this.assertAgentPromptGeneration(ptyId, generation) - this.assertAgentPromptPermissionSafe( - permissionBaseline, - this.getAgentPromptActivity(handle, ptyId) - ) - }, - writeEnter: () => { - const written = - this.ptyController?.write(ptyId, AGENT_PROMPT_SUBMIT, options.inputKind) === true - extraSubmits += written ? 1 : 0 - return written - } - }) if (!options.acceptQueued || !options.requestId) { await verifyAgentPromptSubmission({ baseline, readActivity: () => this.getAgentPromptActivity(handle, ptyId, waitTextCache), timeoutMs: effectTimeoutMs, - signal: options.signal, - resubmit + signal: options.signal }) - return { submits: 1 + extraSubmits } + return { submits: 1 } } const binding = this.getTerminalPromptRequestBinding(handle) - const settlementAgent = resolveTerminalPromptSettlementAgent( - foregroundAgent, - launchAgent, - (agent) => isAgentStatusHooksEnabledForAgent(this.store?.getSettings?.(), agent) - ) + const foregroundAgent = this.ptysById.get(ptyId)?.foregroundAgent + const launchAgent = this.ptysById.get(ptyId)?.launchAgent + const settlementAgent = isTerminalSendSettlementAgent(foregroundAgent) + ? foregroundAgent + : isTerminalSendSettlementAgent(launchAgent) + ? launchAgent + : null const inputAccepted: RuntimeTerminalPromptDelivery = { requestId: options.requestId, stages: ['input_accepted'], @@ -195,12 +171,11 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso ), allowOutputEvidence: false, signal: options.signal, - timeoutMs: options.observationTimeoutMs ?? effectTimeoutMs, - resubmit + timeoutMs: options.observationTimeoutMs ?? effectTimeoutMs }) this.forgetAgentPromptRequest(ptyId, generation, options.requestId) return { - submits: 1 + extraSubmits, + submits: 1, prompt: { ...inputAccepted, stages: ['input_accepted', 'turn_started'] @@ -208,12 +183,12 @@ export class OrcaRuntimeWithWriteTerminalAgentPrompt extends OrcaRuntimeWithReso } } catch (error) { if (error instanceof Error && error.message === 'agent_prompt_stalled') { - return { submits: 1 + extraSubmits, prompt: inputAccepted } + return { submits: 1, prompt: inputAccepted } } if (error instanceof Error && error.message === 'agent_prompt_blocked') { this.forgetAgentPromptRequest(ptyId, generation, options.requestId) return { - submits: 1 + extraSubmits, + submits: 1, prompt: { ...inputAccepted, observation: 'permission' } } } diff --git a/src/main/runtime/readiness-census-transcript-catalog.ts b/src/main/runtime/readiness-census-transcript-catalog.ts index 4ee9ee7c624b..050190018fb0 100644 --- a/src/main/runtime/readiness-census-transcript-catalog.ts +++ b/src/main/runtime/readiness-census-transcript-catalog.ts @@ -45,6 +45,8 @@ const RUNTIME_RECORDERS: readonly (readonly [string, Recorder])[] = [ ['hermes-', { agent: 'hermes', foregroundProcess: 'hermes' }], ['muse-', { agent: 'muse', foregroundProcess: 'muse' }], ['omp-', { agent: 'omp', foregroundProcess: 'omp' }], + // The plain `opencode` command running an OpenCode 2 binary, as the opencode row launches it. + ['opencode-cmd-', { agent: 'opencode', foregroundProcess: 'opencode' }], ['opencode-2-', { agent: 'opencode2', foregroundProcess: 'opencode' }], ['opencode-1-', { agent: 'opencode', foregroundProcess: 'opencode' }], ['prime-agent-', { agent: 'prime-agent', foregroundProcess: 'prime-agent' }], 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..7474ccea39c5 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 @@ -85,12 +85,7 @@ describe('pty-bound agent draft readiness budget', () => { await vi.advanceTimersByTimeAsync(10_000) await expect(promise).resolves.toBe(true) - expect(testState.waitForReady).toHaveBeenCalledWith( - 'pty-1', - 20_000, - 'render-cursor-after-bracketed-paste', - {} - ) + expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, 'opencode-agent-row', {}) expect(testState.sendInput).toHaveBeenCalledTimes(1) expect(onUnconfirmedDelivery).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index ca5f39304175..91cfb42d21fc 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -69,6 +69,9 @@ vi.mock('@/runtime/runtime-terminal-stream', () => ({ const DECSET_BRACKETED_PASTE = '\x1b[?2004h' const SHOW_CURSOR = '\x1b[?25h' +// OpenCode's box and cursor, then the agent/model row it paints once its agent list loads. +const OPENCODE_BOX = `\x1b[?1049h${DECSET_BRACKETED_PASTE}${SHOW_CURSOR}` +const OPENCODE_AGENT_ROW = 'Build \u00b7 Some Model' const CODEX_COMPOSER_PROMPT_RENDER = '\x1b[1m›\x1b[0m Ask Codex to do anything' const CODEX_DYNAMIC_COMPOSER_PROMPT_RENDER = '\x1b[?1049h\x1b[1m›\x1b[0m Implement {feature}' const ISSUE_URL = 'https://github.com/stablyai/orca/issues/123' @@ -205,7 +208,7 @@ describe('pasteDraftWhenAgentReady', () => { ) }) - it('pastes into opencode as soon as show-cursor renders after bracketed paste is enabled', async () => { + it('pastes into opencode once the agent row renders under its box, not on the box alone', async () => { const promise = pasteDraftWhenAgentReady({ tabId: 'tab-1', content: ISSUE_URL, @@ -213,11 +216,11 @@ describe('pasteDraftWhenAgentReady', () => { }) await flushMicrotasks() - testState.ptyObserver?.(DECSET_BRACKETED_PASTE) + testState.ptyObserver?.(OPENCODE_BOX) await flushMicrotasks() expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() - testState.ptyObserver?.(SHOW_CURSOR) + testState.ptyObserver?.(OPENCODE_AGENT_ROW) await expect(promise).resolves.toBe(true) expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( @@ -236,7 +239,7 @@ describe('pasteDraftWhenAgentReady', () => { }) await flushMicrotasks() - testState.ptyObserver?.(DECSET_BRACKETED_PASTE) + testState.ptyObserver?.(OPENCODE_BOX) await flushMicrotasks() for (let index = 0; index < 5; index += 1) { await vi.advanceTimersByTimeAsync(1499) @@ -245,7 +248,7 @@ describe('pasteDraftWhenAgentReady', () => { expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() } - testState.ptyObserver?.(SHOW_CURSOR) + testState.ptyObserver?.(OPENCODE_AGENT_ROW) await expect(promise).resolves.toBe(true) expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( @@ -259,7 +262,7 @@ describe('pasteDraftWhenAgentReady', () => { it('does not paste on the quiet window for opencode (it never arms one)', async () => { // Why: opencode is silent for ~1.5-2s between enabling bracketed paste and // mounting its composer. A quiet window would fire during that gap and paste - // before the composer exists (the original bug), so the cursor signal must + // before the composer exists (the original bug), so its readiness signal must // not arm one. With process inspection failing, delivery times out instead. testState.inspectRuntimeTerminalProcess.mockResolvedValue(null) const onTimeout = vi.fn() diff --git a/src/shared/draft-paste-ready-scanner.test.ts b/src/shared/draft-paste-ready-scanner.test.ts index feaa40fa260a..e68ffb51fd2c 100644 --- a/src/shared/draft-paste-ready-scanner.test.ts +++ b/src/shared/draft-paste-ready-scanner.test.ts @@ -14,7 +14,7 @@ const GROK_ALT_SCREEN_LEAVE = '\x1b[?1049l\x1b[?25h' const GROK_COMPOSER_FRAME = '\x1b[38;2;80;80;88m│\x1b[38;2;200;200;200m❯ \x1b[0m' describe('createDraftPasteReadyScanner', () => { - describe('render-cursor-after-bracketed-paste (opencode / mimo-code)', () => { + describe('render-cursor-after-bracketed-paste (mimo-code)', () => { it('is ready when show-cursor renders after bracketed paste in one chunk', () => { const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${SHOW_CURSOR}`)).toEqual({ @@ -120,6 +120,45 @@ describe('createDraftPasteReadyScanner', () => { }) }) + describe('opencode-agent-row', () => { + // OpenCode's box, then the row under it once its agent list has loaded (shape from 2.0.21). + const BOX = `${ALT_SCREEN_ENTER}${DECSET_BRACKETED_PASTE}┃ Ask anything…${SHOW_CURSOR}` + const AGENT_ROW = '\x1b[29;47HBuild\x1b[0m\x1b[29;53H\u00b7\x1b[0m\x1b[29;55HSome Model' + + it('is not ready on the input box and its cursor alone', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + expect(scanner.observe(BOX)).toEqual({ ready: false, armQuietTimer: false }) + }) + + it('is ready once the agent row paints its separator', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + scanner.observe(BOX) + expect(scanner.observe(AGENT_ROW)).toEqual({ ready: true, armQuietTimer: false }) + }) + + it('ignores a separator a shell prompt draws before OpenCode enters the alternate screen', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + expect( + scanner.observe( + `${DECSET_BRACKETED_PASTE}~ \u00b7 main % opencode${DECRST_BRACKETED_PASTE}` + ) + ).toEqual({ ready: false, armQuietTimer: false }) + expect(scanner.observe(BOX).ready).toBe(false) + }) + + it('ignores a separator painted after OpenCode leaves the alternate screen', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + scanner.observe(BOX) + expect(scanner.observe(`${ALT_SCREEN_LEAVE}~ \u00b7 main %`).ready).toBe(false) + }) + + it('never arms the quiet window', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + expect(scanner.observe(BOX).armQuietTimer).toBe(false) + expect(scanner.observe('more frames').armQuietTimer).toBe(false) + }) + }) + describe('codex-composer-prompt', () => { it('is ready on the composer glyph after bracketed paste and never arms the quiet timer', () => { const scanner = createDraftPasteReadyScanner('codex-composer-prompt') diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index e613231f445c..a9161887d687 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -26,6 +26,10 @@ const GROK_COMPOSER_PROMPT = '❯' // means the composer specifically. Anchored on the alternate-screen switch for the same // reason as grok: a powerline shell prompt can also draw `╭`. const ZCODE_COMPOSER_BOX_CORNER = '╭' +// Why: the separator OpenCode paints between the agent and the model in the row under its input +// box. That row is drawn only once the agent list has loaded from OpenCode's server; until then +// its composer drops Enter silently, so the box alone is not ready. Structural, not a name. +const OPENCODE_AGENT_ROW_SEPARATOR = '\u00b7' const DECSET_ALT_SCREEN = '\x1b[?1049h' const DECRST_ALT_SCREEN = '\x1b[?1049l' @@ -40,7 +44,18 @@ type DraftPasteReadySignalSpec = { quietAnchor: string | null } -const DRAFT_PASTE_READY_SIGNALS: Record = { +/** Signals that are ready only once every listed part has fired, in any order. */ +const ALL_OF_SIGNALS: Partial> = { + // Why both: OpenCode 2 shows its box cursor before the row; OpenCode 1 paints the row just + // before the cursor in the frame that draws the box, so waiting for both delays neither. + 'opencode-agent-row': ['render-cursor-after-bracketed-paste', 'opencode-agent-row-separator'] +} + +type SingleSignal = + | Exclude + | 'opencode-agent-row-separator' + +const DRAFT_PASTE_READY_SIGNALS: Record = { 'codex-composer-prompt': { markerAnchor: DECSET_BRACKETED_PASTE, markerAnchorEnd: null, @@ -90,6 +105,14 @@ const DRAFT_PASTE_READY_SIGNALS: Record DraftPasteReadyScanResult +} { + if (isSingleSignal(readySignal)) { + return createSingleSignalScanner(readySignal) + } + const scanners = (ALL_OF_SIGNALS[readySignal] ?? []).map(createSingleSignalScanner) + const fired = scanners.map(() => false) + return { + observe(data: string): DraftPasteReadyScanResult { + scanners.forEach((scanner, index) => { + fired[index] ||= scanner.observe(data).ready + }) + return { ready: fired.length > 0 && fired.every(Boolean), armQuietTimer: false } + } + } +} + +function isSingleSignal(signal: DraftPasteReadySignal): signal is SingleSignal { + return signal in DRAFT_PASTE_READY_SIGNALS +} + +function createSingleSignalScanner(readySignal: SingleSignal): { + observe: (data: string) => DraftPasteReadyScanResult } { let recent = '' let postAnchorRecent = '' diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index 9e537a9c72e1..4fc5b09fda99 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -223,8 +223,7 @@ export type RuntimeTerminalPromptStage = 'input_accepted' | 'turn_started' export type RuntimeTerminalPromptDelivery = { requestId: string stages: RuntimeTerminalPromptStage[] - /** The agent whose turn start Orca observes for this prompt, or why none is observed. */ - provider: TuiAgent | 'unsupported' | 'old-host' + provider: 'claude' | 'codex' | 'unsupported' | 'old-host' observation: 'supported' | 'unsupported' | 'incarnation_replaced' | 'permission' processIncarnation: string generation: number diff --git a/src/shared/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts index 91e5a4fbf151..b4188549de8a 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -13,6 +13,7 @@ export type DraftPasteReadySignal = | 'grok-composer-prompt' | 'dsh-composer-prompt' | 'zcode-composer-prompt' + | 'opencode-agent-row' export type TuiAgentDetectionRuntime = NodeJS.Platform | 'wsl' @@ -47,12 +48,6 @@ export type TuiAgentConfig = { composerReadyCaptures?: readonly string[] /** Delay before one extra blind submit Enter, for agents that render their composer before Enter is live (codex); a no-op if the first Enter landed. */ submitRetryDelayMs?: number - /** - * Main-process prompt submit: press Enter again every `intervalMs`, at most `maxRetries` times, - * until this pane's status hooks report the turn started. For TUIs that draw their composer - * before Enter is live. Never sent without hook evidence for the pane, or past a prompt. - */ - submitRetryUntilTurnStart?: { intervalMs: number; maxRetries: number } /** Extra ms per logical prompt line before Enter, for TUIs that expand multiline paste slowly (antigravity). */ submitLineSettleMsPerLine?: number /** Windows Shift+Enter encoding override; omitted agents keep the legacy Esc+CR path. */ diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index a29a9f788e0f..e9ccccf72eb6 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -23,11 +23,6 @@ function resolveTuiAgentConfig(source: TuiAgentConfigSource): TuiAgentConfig { } } -// Why 1.5 s x 5: an Enter after the agent list loads always submitted in measured runs, and the -// load trailed the composer by up to 2 s on a loaded machine; 7.5 s leaves headroom inside the -// 30 s observation window without leaning on an Enter that a dialog could consume. -const OPENCODE_SUBMIT_RETRY = { intervalMs: 1_500, maxRetries: 5 } - const TUI_AGENT_CONFIG_SOURCE: Record = { claude: { detectCmd: 'claude', @@ -96,8 +91,9 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { opencode: { detectCmd: 'opencode', promptInjectionMode: 'flag-prompt', - // Why: opencode enables bracketed paste before its composer mounts; wait for the post-\x1b[?2004h show-cursor so paste lands. - draftPasteReadySignal: 'render-cursor-after-bracketed-paste', + // Why: OpenCode 2 draws its input box before its agent list loads and drops an Enter sent + // in between; the agent/model row under the box is the first moment it can submit. + draftPasteReadySignal: 'opencode-agent-row', // Why 20s: measured on two Windows hosts (ConPTY dll backend, as pinned by // local-pty-utils), opencode does not enable bracketed paste until ~4.8s and its // composer is not ready until ~10s — so the 8s default expired first and the draft @@ -107,28 +103,30 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { 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 silently drops an Enter until it has loaded its agent list from its server, - // which can trail the composer by seconds on a loaded machine (STA-9359). - submitRetryUntilTurnStart: OPENCODE_SUBMIT_RETRY - }, - // Why: opencode2 installs as a separate binary and uses the same prompt flags. - // Its @opentui composer keeps the same cursor-gated paste signal. + 'opencode-1-18-32-timed-first-launch', + 'opencode-cmd-2-0-21-timed-warm-server' + ] + }, + // Why: opencode2 installs as a separate binary and uses the same prompt flags and the same + // agent-row paste signal. opencode2: { detectCmd: 'opencode2', // The private server inherits this pane's hook endpoint and identity. launchCmd: 'opencode2 --standalone', promptInjectionMode: 'flag-prompt', - draftPasteReadySignal: 'render-cursor-after-bracketed-paste', + draftPasteReadySignal: 'opencode-agent-row', draftPasteReadyTimeoutMs: 20_000, - composerReadyCaptures: ['opencode-2-0-18-timed-boot-hidden-pane'], - submitRetryUntilTurnStart: OPENCODE_SUBMIT_RETRY + composerReadyCaptures: [ + 'opencode-2-0-18-timed-boot-hidden-pane', + 'opencode-2-0-21-timed-cold-standalone', + 'opencode-2-0-21-timed-cold-standalone-hidden-pane', + 'opencode-2-0-21-timed-busy-standalone' + ] }, 'mimo-code': { detectCmd: 'mimo', promptInjectionMode: 'flag-prompt', - // Why: mirrors opencode's cursor-gated signal by parity; mimo's startup stream isn't separately validated. + // Why: mirrors OpenCode's earlier cursor-gated signal by parity; mimo's startup stream isn't separately validated. draftPasteReadySignal: 'render-cursor-after-bracketed-paste' }, pi: { diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 31690889e9f3..0428f139e0eb 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -601,10 +601,9 @@ describe('tui agent startup plans', () => { expect(plan?.launchCommand).toBe("opencode --prompt 'fix it'") }) - it('keeps opencode and mimo-code on the cursor-gated paste draft route', () => { - expect(TUI_AGENT_CONFIG.opencode.draftPasteReadySignal).toBe( - 'render-cursor-after-bracketed-paste' - ) + it('keeps opencode on the agent-row and mimo-code on the cursor-gated paste draft route', () => { + expect(TUI_AGENT_CONFIG.opencode.draftPasteReadySignal).toBe('opencode-agent-row') + expect(TUI_AGENT_CONFIG.opencode2.draftPasteReadySignal).toBe('opencode-agent-row') expect(TUI_AGENT_CONFIG.opencode.draftPromptFlag).toBeUndefined() expect(TUI_AGENT_CONFIG.opencode.draftPromptEnvVar).toBeUndefined() expect(TUI_AGENT_CONFIG['mimo-code'].draftPasteReadySignal).toBe( From 2dff8f0fc2d79da355903e1e0c71d92079eccbe5 Mon Sep 17 00:00:00 2001 From: m4air Date: Sat, 3 Oct 2026 23:00:11 -0700 Subject: [PATCH 3/9] fix(opencode): prove the agent-row signal with natural captures and never paste a draft blind Replaces three recordings made by launching four OpenCode boots at once with natural single-run evidence: one cold start that pasted on the agent row and pressed Enter on Orca's real 500 ms timing (submitted), and one cold start recorded under heavy load from other sessions, where the paste on the box's cursor and Orca's Enter came before the agent row and the brief stayed unsent. Adds a 2.0.14 cold start as a cross-version check. The renderer's draft paste no longer falls back to a blind paste when the OpenCode agent-row signal never arrives: OpenCode would drop the Enter and leave the draft in its box, so the caller gets its timeout notice instead. Other agents keep the fallback. Co-Authored-By: Claude --- ...ode-2-0-14-timed-cold-standalone.meta.json | 9 +++ ...e-2-0-14-timed-cold-standalone.timing.json | 1 + .../opencode-2-0-14-timed-cold-standalone.txt | 1 + ...ode-2-0-21-timed-busy-standalone.meta.json | 9 --- ...e-2-0-21-timed-busy-standalone.timing.json | 1 - .../opencode-2-0-21-timed-busy-standalone.txt | 1 - ...0-21-timed-enter-after-agent-row.meta.json | 9 +++ ...21-timed-enter-after-agent-row.timing.json | 1 + ...ode-2-0-21-timed-enter-after-agent-row.txt | 1 + ...-2-0-21-timed-enter-at-agent-row.meta.json | 9 --- ...-0-21-timed-enter-at-agent-row.timing.json | 1 - ...encode-2-0-21-timed-enter-at-agent-row.txt | 1 - ...encode-2-0-21-timed-enter-at-box.meta.json | 9 --- ...code-2-0-21-timed-enter-at-box.timing.json | 1 - .../opencode-2-0-21-timed-enter-at-box.txt | 1 - ...timed-natural-load-enter-dropped.meta.json | 9 +++ ...med-natural-load-enter-dropped.timing.json | 1 + ...-0-21-timed-natural-load-enter-dropped.txt | 1 + ...e-2-0-14-timed-cold-standalone@agent.json} | 6 +- ...2-0-14-timed-cold-standalone@unknown.json} | 6 +- ...de-2-0-21-timed-busy-standalone@agent.json | 13 ---- ...-2-0-21-timed-busy-standalone@unknown.json | 13 ---- ...-21-timed-enter-after-agent-row@agent.json | 15 ++++ ...1-timed-enter-after-agent-row@unknown.json | 15 ++++ ...med-natural-load-enter-dropped@agent.json} | 6 +- ...d-natural-load-enter-dropped@unknown.json} | 6 +- ...draft-paste-ready-scanner-chunking.test.ts | 4 +- .../launched-agent-composer-readiness.test.ts | 5 +- ...pencode-agent-row-enter-transcript.test.ts | 68 +++++++++++-------- ...pencode-worker-composer-transcript.test.ts | 5 +- ...agent-paste-draft-readiness-budget.test.ts | 32 ++++++++- .../src/lib/agent-paste-draft.test.ts | 23 +++---- src/renderer/src/lib/agent-paste-draft.ts | 20 ++++-- src/shared/tui-agent-config.ts | 3 +- 34 files changed, 177 insertions(+), 129 deletions(-) create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.txt delete mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.meta.json delete mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.timing.json delete mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.txt create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.txt delete mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.meta.json delete mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.timing.json delete mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.txt delete mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.meta.json delete mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.timing.json delete mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.txt create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.txt rename src/main/runtime/__fixtures__/readiness-census/{transcript--opencode-2-0-21-timed-enter-at-agent-row@agent.json => transcript--opencode-2-0-14-timed-cold-standalone@agent.json} (53%) rename src/main/runtime/__fixtures__/readiness-census/{transcript--opencode-2-0-21-timed-enter-at-agent-row@unknown.json => transcript--opencode-2-0-14-timed-cold-standalone@unknown.json} (53%) delete mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-busy-standalone@agent.json delete mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-busy-standalone@unknown.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-after-agent-row@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-after-agent-row@unknown.json rename src/main/runtime/__fixtures__/readiness-census/{transcript--opencode-2-0-21-timed-enter-at-box@agent.json => transcript--opencode-2-0-21-timed-natural-load-enter-dropped@agent.json} (59%) rename src/main/runtime/__fixtures__/readiness-census/{transcript--opencode-2-0-21-timed-enter-at-box@unknown.json => transcript--opencode-2-0-21-timed-natural-load-enter-dropped@unknown.json} (59%) diff --git a/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.meta.json new file mode 100644 index 000000000000..57e46b8f667b --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-04T05:18:50.064162Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.14 (Homebrew opencode-v2) on a macOS laptop, 120x40, alternate screen; a cross-version check for the agent-row signal. Recorded on the laptop with a Python PTY recorder that stamps each read with its ms since spawn; --standalone private server (cold), model opencode/muse-spark-1.3-contributor-free via OPENCODE_CONFIG_CONTENT, every ORCA_* variable stripped, no input. Box cursor at 1366 ms, agent/model row at 1408 ms. The footer path was replaced on the laptop with the same-length ~/projects/demo-01/... . .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs (the last read; the recorder stopped there).", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.timing.json new file mode 100644 index 000000000000..8629dd5ed050 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":1561,"chunks":[[307,280],[307,37],[307,8],[609,1024],[609,1024],[609,1024],[609,1024],[609,1024],[609,577],[1319,1024],[1319,1024],[1319,1024],[1319,1024],[1319,1024],[1320,817],[1325,13],[1366,900],[1366,900],[1366,358],[1376,100],[1408,388],[1425,527],[1442,663],[1459,666],[1476,696],[1493,661],[1510,627],[1527,486],[1544,412],[1561,378]]} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.txt b/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.txt new file mode 100644 index 000000000000..6c038050dd2b --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-14-timed-cold-standalone.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]4;0;?[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀/┃ ┃ Ask anything… "What is the tech stack of this project?" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀~/projects/demo-01/opencode-scratchshift+tab agentsctrl+p commands2.0.14]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l/update to install v2.0.22[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l/upd┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l/update t┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25lpdate to in┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25le to install┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25linstall v2.┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25lall v2.0.22┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l2.0.22┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l22┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Muse Spark 1.3 FreeOpenCode Zen╹[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.meta.json deleted file mode 100644 index e7bbdcaee3ae..000000000000 --- a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.meta.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "capturedAt": "2026-10-04T05:14:45.638746Z", - "platform": "darwin", - "command": ["opencode", "--standalone"], - "cols": 120, - "rows": 40, - "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane), env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs. Why this run: one of four cold --standalone starts launched at the same moment (no CPU stress tool), so the agent list loads late: the agent/model row trails the box by 280 ms. No input typed.", - "exitCode": null -} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.timing.json deleted file mode 100644 index 96c3e2cb90cf..000000000000 --- a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.timing.json +++ /dev/null @@ -1 +0,0 @@ -{"recordedUntilMs":10650.1,"chunks":[[423.7,280],[427.9,53],[445.0,1024],[445.7,1024],[446.3,1024],[447.0,1024],[448.7,1024],[453.5,1024],[458.5,353],[461.4,244],[465.3,1024],[467.0,1024],[471.2,1024],[475.7,1024],[477.8,1024],[481.2,1024],[485.6,353],[492.3,13],[576.4,934],[576.9,938],[577.1,835],[589.7,173],[857.1,476],[876.1,476],[893.4,481],[916.8,490],[933.6,491],[949.4,497],[965.6,326],[982.9,326],[1000.7,326]]} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.txt b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.txt deleted file mode 100644 index 03dbb9a5742d..000000000000 --- a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-busy-standalone.txt +++ /dev/null @@ -1 +0,0 @@ -[?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]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix broken tests" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/Users/uuuuu/orca-...xxxxxx/capture/workshift+tab agentsctrl+p commands2.0.21]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l…/uuuuu/xxxxxxx/xxxxxxxx/capture/work [?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.meta.json new file mode 100644 index 000000000000..7c5d913c79fc --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-04T05:49:55.163902Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane). One natural cold start, no other capture running; load average at start 2.76 / 4.63 / 7.28. --standalone with the user's own login and plugins read in place and the model opencode-go/deepseek-v4.1-flash set through OPENCODE_CONFIG_CONTENT (nothing on disk written by the recorder); OPENCODE_DISABLE_AUTOUPDATE=1, no ORCA_* vars; version and binary sha256 unchanged before and after. An 82-line, 6.6 KB brief was bracketed-pasted the moment the agent/model row painted (393 ms; box cursor at 273 ms) and Enter was sent 537 ms later (promptSentAtMs), Orca's submit delay of 500 ms plus paste time. OpenCode submitted it: the composer cleared and the brief and the reply were painted. .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-21-timed-enter-after-agent-row.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.timing.json new file mode 100644 index 000000000000..63d04fa32aa8 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":15995.5,"chunks":[[211.0,280],[212.6,69],[221.4,1024],[221.7,1024],[222.0,1024],[222.3,1024],[222.5,1024],[222.8,1024],[223.0,581],[226.5,13],[239.8,1024],[240.4,1024],[240.7,1024],[241.0,1024],[241.2,1024],[241.5,1024],[242.2,353],[271.1,1024],[271.6,1024],[271.8,952],[272.1,928],[272.3,888],[272.5,412],[328.5,338],[358.1,1016],[358.6,1018],[359.0,935],[374.5,1016],[375.1,1018],[375.4,979],[392.9,1016],[393.2,1018],[393.4,1017],[393.8,236],[409.7,1016],[410.0,1018],[410.2,1016],[410.4,413],[426.4,1018],[426.7,1018],[426.9,1018],[427.1,672],[443.5,1018],[443.7,1018],[444.0,1018],[444.2,677],[460.1,1018],[460.4,1018],[460.6,1018],[460.8,683],[477.6,1016],[477.9,1018],[478.2,1016],[478.4,249],[495.5,1014],[495.8,1014],[496.1,363],[514.6,326],[532.2,326],[549.4,326],[583.7,369],[601.1,1014],[601.4,1014],[601.7,77],[623.0,1016],[623.4,1016],[623.7,542],[640.8,1016],[641.1,1016],[641.3,542],[657.5,1014],[657.8,1014],[658.1,77],[675.1,1016],[675.6,1018],[675.9,979],[693.8,1016],[694.1,1018],[694.3,979],[712.9,1016],[713.2,1016],[713.8,542],[729.6,1014],[729.9,1014],[730.2,77],[749.7,1016],[750.0,1018],[750.2,979],[768.1,1016],[768.4,1016],[768.6,512],[785.8,1016],[786.0,1016],[786.3,542],[804.2,1016],[804.5,1016],[804.8,542],[822.8,1012],[823.1,624],[840.6,1016],[840.9,1016],[841.2,542],[859.1,1014],[859.5,1014],[859.8,47],[877.9,1012],[878.4,624],[955.0,119],[966.9,13],[993.0,1016],[993.3,1018],[993.6,1016],[993.9,1018],[994.1,1018],[994.4,1016],[994.6,1018],[994.9,1016],[995.1,1018],[995.4,1015],[995.6,791],[995.8,87],[998.8,285],[1018.6,254],[1035.4,297],[1039.3,13],[1054.4,992],[1054.7,976],[1055.0,106],[1072.4,259],[1089.7,86],[1107.9,180],[1141.6,225],[1158.9,86],[1177.2,269],[1219.6,346],[1238.2,259],[1255.3,119],[1282.4,357],[1291.1,226],[1309.7,482],[1326.7,226],[1344.1,438],[1362.7,259],[1381.6,152],[1399.0,193],[1416.9,226],[1435.3,426],[1453.7,192],[1472.2,432],[1490.1,309],[1507.5,412],[1525.9,221],[1542.3,195],[1560.8,445],[1579.8,241],[1596.9,287],[1614.8,357],[1633.1,276],[1650.5,239],[1667.7,290],[1684.2,409],[1701.8,225],[1719.8,276],[1738.4,240],[1756.5,416],[1774.9,240],[1792.3,495],[1809.0,241],[1826.9,326],[1845.7,558],[1863.8,279],[1881.0,653],[1897.2,275],[1913.9,593],[1930.5,428],[1947.3,275],[1965.8,722],[1983.3,410],[2000.4,630],[2023.4,392],[2037.2,294],[2055.2,341],[2073.1,345],[2091.6,680],[2110.3,392],[2127.2,569],[2146.2,477],[2164.4,609],[2183.0,378],[2200.0,480],[2217.1,446],[2234.2,397],[2251.7,541],[2268.3,277],[2286.2,535],[2304.0,446],[2320.6,399],[2338.8,329],[2357.2,434],[2374.4,432],[2393.1,430],[2412.4,381],[2430.8,434],[2448.8,450],[2465.5,429],[2484.2,365],[2502.4,312],[2521.1,451],[2538.7,398],[2552.9,32],[2555.5,845],[2573.6,315],[2591.3,452],[2608.3,1024],[2609.0,1024],[2609.6,628],[2625.6,380],[2644.2,314],[2660.7,436],[2678.3,379],[2691.5,32],[2695.6,1022],[2695.9,1024],[2696.3,1023],[2696.8,261],[2713.8,773],[2729.8,929],[2747.9,722],[2764.2,806],[2782.8,722],[2801.5,755],[2820.0,634],[2838.8,315],[2857.3,608],[2875.9,429],[2894.5,555],[2913.7,484],[2930.8,513],[2948.7,412],[2967.4,599],[2985.8,433],[3004.5,414],[3028.3,410],[3042.8,414],[3060.6,313],[3079.4,313],[3098.1,263],[3133.6,263],[3170.8,263],[3189.4,263],[3208.3,263],[3225.7,263],[3245.0,263],[3262.9,263],[3282.4,263],[3297.2,263],[3315.3,263],[3334.1,263],[3353.0,263],[3371.1,263],[3387.3,263],[3405.7,263],[3422.4,263],[3439.8,263],[3458.2,263],[3476.7,263],[3495.2,263],[3512.6,263],[3531.0,263],[3548.0,263],[3566.2,263],[3584.6,263],[3602.8,263],[3621.3,263],[3639.8,263],[3658.1,263],[3694.1,263],[3730.1,263],[3747.1,263],[3763.8,263],[3891.9,182]],"promptSentAtMs":938.3} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.txt b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.txt new file mode 100644 index 000000000000..a16f3a3bd64a --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-after-agent-row.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[?2027h[?2004h[?2026h[?25l                                        [?2026l[?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]0;OpenCode[?2026h[?25l                                        [?2026l[?2026h[?25lOrca rOrca rOrca rOrca rOrca rOrca rOrca rORCAPIOrca rORCAPI + New session   ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix broken tests" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀~/xxxxxxx/xxxxxxxx/capture/workshift+tab agentsctrl+p commands2.0.21]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l • ORCA[?25h[?2026l[?2026h[?25l • Orca • Orca • Orca • Orca • Orca • Orca • Orca • ORCA • Orca • ORC[?25h[?2026l[?2026h[?25l • Orca • Orca • Orca • Orca • Orca • Orca • Orca • ORCA • Orca • ORCA[?25h[?2026l[?2026h[?25l • Orca • Orca • Orca • Orca • Orca • Orca • Orca • ORCA • Orca • ORCA·DeepSeek V4.1 FlashOpenCode Go·high[?25h[?2026l[?2026h[?25l • Orca • Orca • Orca • Orca • Orca • Orca • Orca • ORCA • Orca • ORCA┃┃[Pasted ~82 lines]  ┃┃Build╹[?25h[?2026l[?2026h[?25l • Orca  • Orca  • Orca  • Orca  • Orca  • Orca  • Orca  • ORCAP • Orca  • ORCAP┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l • Orca  • Orca  • Orca  • Orca  • Orca  • Orca  • Orca  • ORCAP • Orca  • ORCAP┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l • Orca  • Orca  • Orca  • Orca  • Orca  • Orca  • Orca  • ORCAP • Orca  • ORCAP┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l • Orca • Orca • Orca • Orca • Orca • Orca • Orca • ORCA • Orca • ORCA┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l • Or • Or • Or • Or • Or • Or • Or • OR • Or • OR┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l          [?25h[?2026l[?2026h[?25l • Or • Or • Or • Or • Or • Or • Or • OR • Or • OR[?25h[?2026l[?2026h[?25l • Orc • Orc • Orc • Orc • Orc • Orc • Orc • ORC • Orc • ORC[?25h[?2026l[?2026h[?25l • Orc • Orc • Orc • Orc • Orc • Orc • Orc • ORC • Orc • ORC[?25h[?2026l[?2026h[?25l • Or • Or • Or • Or • Or • Or • Or • OR • Or • OR[?25h[?2026l[?2026h[?25l • Orca • Orca • Orca • Orca • Orca • Orca • Orca • ORCA • Orca • ORCA[?25h[?2026l[?2026h[?25l • Orca • Orca • Orca • Orca • Orca • Orca • Orca • ORCA • Orca • ORCA[?25h[?2026l[?2026h[?25l • Orc • Orc • Orc • Orc • Orc • Orc • Orc • ORC • Orc • ORC[?25h[?2026l[?2026h[?25l • Or • Or • Or • Or • Or • Or • Or • OR • Or • OR[?25h[?2026l[?2026h[?25l • Orc  • Orc  • Orc  • Orc  • Orc  • Orc  • Orc  • ORCP • Orc  • ORCP[?25h[?2026l[?2026h[?25l • Ora • Ora • Ora • Ora • Ora • Ora • Ora • ORA • Ora • ORA[?25h[?2026l[?2026h[?25l • Orc • Orc • Orc • Orc • Orc • Orc • Orc • ORC • Orc • ORC[?25h[?2026l[?2026h[?25l • Orc • Orc • Orc • Orc • Orc • Orc • Orc • ORC • Orc • ORC[?25h[?2026l[?2026h[?25l • O • O • O • O • O • O • O • O • O • O[?25h[?2026l[?2026h[?25l • Orc • Orc • Orc • Orc • Orc • Orc • Orc • ORC • Orc • ORC[?25h[?2026l[?2026h[?25l • Oa • Oa • Oa • Oa • Oa • Oa • Oa • OA • Oa • OA[?25h[?2026l[?2026h[?25l • O • O • O • O • O • O • O • O • O • O[?25h[?2026l[?2026h[?25lAsk anything… "Fix broken tests"[?25h[?2026l]0;OpenCode[?2026h[?25l┃ 52. filler line standing in for the dispatch preamble so the paste has its size. ┃ 53. filler line standing in for the dispatch preamble so the paste has its size. ┃ 54. filler line standing in for the dispatch preamble so the paste has its size. ┃ 55. filler line standing in for the dispatch preamble so the paste has its size. ┃ 56. filler line standing in for the dispatch preamble so the paste has its size. ┃ 57. filler line standing in for the dispatch preamble so the paste has its size. ┃ 58. filler line standing in for the dispatch preamble so the paste has its size. ┃ 59. filler line standing in for the dispatch preamble so the paste has its size. ┃ 60. filler line standing in for the dispatch preamble so the paste has its size. ┃ 61. filler line standing in for the dispatch preamble so the paste has its size. ┃ 62. filler line standing in for the dispatch preamble so the paste has its size. ┃ 63. filler line standing in for the dispatch preamble so the paste has its size. ┃ 64. filler line standing in for the dispatch preamble so the paste has its size. ┃ 65. filler line standing in for the dispatch preamble so the paste has its size. ┃ 66. filler line standing in for the dispatch preamble so the paste has its size. ┃ 67. filler line standing in for the dispatch preamble so the paste has its size. ┃ 68. filler line standing in for the dispatch preamble so the paste has its size. ┃ 69. filler line standing in for the dispatch preamble so the paste has its size. ┃ 70. filler line standing in for the dispatch preamble so the paste has its size. ┃ 71. filler line standing in for the dispatch preamble so the paste has its size. ┃ 72. filler line standing in for the dispatch preamble so the paste has its size. ┃ 73. filler line standing in for the dispatch preamble so the paste has its size. ┃ 74. filler line standing in for the dispatch preamble so the paste has its size. ┃ 75. filler line standing in for the dispatch preamble so the paste has its size. ┃ 76. filler line standing in for the dispatch preamble so the paste has its size. ┃ 77. filler line standing in for the dispatch preamble so the paste has its size. ┃ 78. filler line standing in for the dispatch preamble so the paste has its size. ┃ 79. filler line standing in for the dispatch preamble so the paste has its size. ┃ 80. filler line standing in for the dispatch preamble so the paste has its size. ┃ Task: reply with the single word ORCAPING and nothing else.  ┃ ┃ ┃ ┃ ┃ Build · DeepSeek V4.1 Flash OpenCode Go · high ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀~/xxxxxxx/xxxxxxxx/capture/workshift+tab agentsctrl+p commands[?25h[?2026l[?2026h[?25l ⠋+N [?25h[?2026l[?2026h[?25l ⠋ New + [?25h[?2026l[?2026h[?25l ⠋ New session  + [?25h[?2026l]0;OpenCode[?2026h[?25l ⠋ New session  + ┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃ ■⬝⬝⬝⬝⬝⬝⬝ esc interru [?25h[?2026l[?2026h[?25l ⠋ New session [?25h[?2026l[?2026h[?25l⠙[?25h[?2026l[?2026h[?25l■■⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l■■■⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠹[?25h[?2026l[?2026h[?25l■■■■⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ■■■■■⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠹ New session [?25h[?2026l[?2026h[?25l ⠸[?25h[?2026l[?2026h[?25l■■■■■■⬝⬝[?25h[?2026l[?2026h[?25l⠸ New session [?25h[?2026l[?2026h[?25l ⠸⬝■■■■■■⬝[?25h[?2026l[?2026h[?25l⠼ New session [?25h[?2026l[?2026h[?25l ⠼⬝⬝■■■■■■[?25h[?2026l[?2026h[?25l ⠼ New session [?25h[?2026l[?2026h[?25l ⠼ [?25h[?2026l[?2026h[?25l⠼New session [?25h[?2026l[?2026h[?25l ⠴New session [?25h[?2026l[?2026h[?25l ⠴ ⬝⬝⬝■■■■■[?25h[?2026l[?2026h[?25l⠴ew session [?25h[?2026l[?2026h[?25l ⠴ N⬝⬝⬝⬝■■■■[?25h[?2026l[?2026h[?25l ⠦ New session [?25h[?2026l[?2026h[?25l Nw session ⬝⬝⬝⬝⬝■■■[?25h[?2026l[?2026h[?25l ⠦Ne[?25h[?2026l[?2026h[?25lew session [?25h[?2026l[?2026h[?25l ⠦ New⬝⬝⬝⬝⬝⬝■■[?25h[?2026l[?2026h[?25l⠧e session [?25h[?2026l[?2026h[?25lew ⬝⬝⬝⬝⬝⬝⬝■[?25h[?2026l[?2026h[?25l ⠧ Nwsession [?25h[?2026l[?2026h[?25l ew ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠇w s[?25h[?2026l[?2026h[?25l⠇w s⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠇ New sssion [?25h[?2026l[?2026h[?25l  se[?25h[?2026l[?2026h[?25l  se⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠏ses[?25h[?2026l[?2026h[?25l ⠏ses⬝⬝⬝⬝⬝⬝■■[?25h[?2026l[?2026h[?25l⠏Nes[?25h[?2026l[?2026h[?25l ⠏ ess⬝⬝⬝⬝⬝■■■[?25h[?2026l[?2026h[?25l⠋ess[?25h[?2026l[?2026h[?25l ⠋essi[?25h[?2026l[?2026h[?25l⠋ Nssi⬝⬝⬝⬝■■■■[?25h[?2026l[?2026h[?25l wssi[?25h[?2026l[?2026h[?25l⠋ Nesio⬝⬝⬝■■■■■[?25h[?2026l[?2026h[?25l ⠙sio[?25h[?2026l[?2026h[?25l⠙  io⬝⬝■■■■■■[?25h[?2026l[?2026h[?25l ⠙Newion[?25h[?2026l[?2026h[?25l⠙ ion[?25h[?2026l[?2026h[?25l ⠙Nson ⬝■■■■■■⬝[?25h[?2026l[?2026h[?25l⠹ ew on [?25h[?2026l[?2026h[?25l Neon ■■■■■■⬝⬝[?25h[?2026l[?2026h[?25l⠹ ewsn  [?25h[?2026l[?2026h[?25l N n  [?25h[?2026l[?2026h[?25l⠸ ews  [?25h[?2026l[?2026h[?25lN se   [?25h[?2026l[?2026h[?25l ⠸ ews   ■■■■■⬝⬝⬝[?25h[?2026l[?2026h[?25l⠸Ne s   [?25h[?2026l[?2026h[?25l⠸wes   ■■■■⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠼ Ne si   [?25h[?2026l[?2026h[?25l ⠼wess   ■■■⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l Ne s   [?25h[?2026l[?2026h[?25l⠼weo   ■■⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠴ sssi   [?25h[?2026l[?2026h[?25lNewen   [?25h[?2026l[?2026h[?25l⠴ ssso   ■⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ei   [?25h[?2026l[?2026h[?25lew sss    ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠦Neion   [?25h[?2026l[?2026h[?25lw ss   ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25lsei    [?25h[?2026l[?2026h[?25lesson    [?25h[?2026l[?2026h[?25l sei    ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠧sson    [?25h[?2026l[?2026h[?25lwei    ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25lssson    [?25h[?2026l[?2026h[?25l io      ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠇essn    [?25h[?2026l[?2026h[?25lio     ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25lsn     [?25h[?2026l[?2026h[?25lssio      [?25h[?2026l[?2026h[?25len      ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l]0;OC | Orca readiness capture[?2026h[?25l⠏Orca readiess capture   $0.00[?25h[?2026l[?2026h[?25lnsp  ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ees ca  [?25h[?2026l[?2026h[?25ldinst  45678960612345678970712345678980Task: reply with he singlewd ORCAPING and nothingelse.     ⠋ Thinking ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠋es cp [?25h[?2026l[?2026h[?25l ansa [?25h[?2026l[?2026h[?25l ies cu⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠋dsapt[?25h[?2026l]0;OC | Orca readiness capture[?2026h[?25l • Orca readisapture 89606162634567897071727345678980Task: reply with he singlewd ORCAPING and nothingelse.     + Thought · 182ms   Build · DeepSeek V4.1 Flash · 1.7s · 12.8 tok/s~/xxxxxxx/xxxxxxxx/caure/work7.0K (1%) · [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture ORCAPING[?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture  [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture  [?25h[?2026l[?2026h[?25l • Orca readinss captur[?25h[?2026l[?2026h[?25l pture [?25h[?2026l[?2026h[?25l  Os capture [?25h[?2026l[?2026h[?25l Orca readinespture [?25h[?2026l[?2026h[?25l • Oscature [?25h[?2026l[?2026h[?25l •Orca readiness capture [?25h[?2026l[?2026h[?25l • rca readiness atue [?25h[?2026l[?2026h[?25l • ca readiness cpure [?25h[?2026l[?2026h[?25l • Orca readiness capture  [?25h[?2026l[?2026h[?25l • captue  [?25h[?2026l[?2026h[?25l • Ora readiness capture [?25h[?2026l[?2026h[?25l • Ora readiness capte [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l • Orca readiness capture [?25h[?2026l[?2026h[?25l   [?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.meta.json deleted file mode 100644 index 86917d47c311..000000000000 --- a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.meta.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "capturedAt": "2026-10-04T05:15:09.440812Z", - "platform": "darwin", - "command": ["opencode", "--standalone"], - "cols": 120, - "rows": 40, - "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane), env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs. Why this run: one of four simultaneous cold --standalone starts; a 32-line bracketed paste was written the moment the agent/model row was painted and Enter 37 ms after the paste (promptSentAtMs). OpenCode submitted it: the composer cleared and the brief and the reply were painted. Positive control for the agent-row signal.", - "exitCode": null -} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.timing.json deleted file mode 100644 index 8d5429838006..000000000000 --- a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.timing.json +++ /dev/null @@ -1 +0,0 @@ -{"recordedUntilMs":16093.9,"chunks":[[443.9,312],[446.1,5],[446.9,16],[447.0,16],[447.1,48],[447.1,72],[447.2,84],[447.3,24],[458.4,1024],[459.1,1024],[460.0,1024],[462.2,1024],[462.8,1024],[463.4,1024],[465.0,353],[465.4,13],[562.2,934],[562.5,938],[562.9,835],[579.1,173],[985.9,485],[1006.2,518],[1024.0,325],[1052.1,13],[1066.3,1016],[1067.0,1018],[1067.7,1016],[1067.9,1018],[1068.2,1016],[1068.4,1018],[1068.6,1018],[1068.9,1016],[1069.1,1018],[1069.3,1015],[1069.6,694],[1074.4,1022],[1075.0,1024],[1075.5,446],[1090.9,259],[1093.4,13],[1107.5,259],[1127.0,990],[1127.3,978],[1127.5,72],[1145.4,259],[1162.7,85],[1181.1,180],[1217.3,225],[1234.6,85],[1252.4,269],[1287.4,71],[1305.8,313],[1324.1,227],[1347.6,389],[1360.3,85],[1376.9,622],[1395.4,117],[1413.7,227],[1432.2,436],[1460.0,227],[1478.7,227],[1497.2,226],[1515.5,473],[1534.2,258],[1552.5,458],[1569.8,217],[1586.7,463],[1603.5,299],[1620.0,296],[1638.3,489],[1656.6,291],[1675.0,316],[1693.3,455],[1711.3,286],[1729.6,267],[1746.3,240],[1764.7,406],[1783.0,268],[1801.0,337],[1819.5,301],[1837.3,430],[1854.5,270],[1872.0,508],[1889.2,269],[1907.6,402],[1926.3,520],[1944.1,452],[1962.6,546],[1979.3,402],[1997.2,671],[2016.9,435],[2034.3,797],[2051.3,335],[2069.2,420],[2086.8,634],[2105.4,417],[2122.2,385],[2140.6,467],[2157.8,690],[2176.1,421],[2194.5,663],[2212.6,335],[2231.3,440],[2250.1,651],[2268.8,473],[2287.5,580],[2306.1,418],[2323.6,570],[2342.0,390],[2359.1,427],[2377.2,351],[2395.5,475],[2413.8,441],[2432.1,374],[2448.6,424],[2467.7,471],[2484.3,307],[2502.7,371],[2520.9,441],[2539.1,373],[2557.4,436],[2575.4,388],[2593.7,357],[2611.4,322],[2629.5,400],[2646.0,439],[2664.3,353],[2682.8,336],[2701.5,387],[2720.1,432],[2737.3,289],[2755.6,381],[2773.1,465],[2790.7,315],[2809.0,399],[2828.1,282],[2846.0,281],[2865.1,366],[2883.6,347],[2902.0,449],[2919.3,332],[2936.8,416],[2953.4,349],[2970.7,303],[2989.1,453],[3006.4,387],[3025.0,372],[3043.3,466],[3060.3,405],[3077.8,270],[3095.0,321],[3113.6,484],[3132.1,399],[3150.5,372],[3169.2,400],[3186.0,484],[3202.8,319],[3219.6,451],[3237.9,419],[3255.3,336],[3272.0,470],[3290.5,353],[3308.4,519],[3326.8,354],[3343.3,581],[3360.5,336],[3377.8,352],[3396.1,576],[3414.4,435],[3432.3,620],[3450.0,368],[3466.6,600],[3485.0,284],[3503.4,482],[3521.6,709],[3540.0,467],[3558.2,736],[3575.5,421],[3593.0,748],[3609.5,386],[3627.3,407],[3645.4,788],[3663.2,372],[3681.4,492],[3698.4,436],[3715.7,1024],[3716.2,1024],[3716.7,810],[3734.2,425],[3752.6,474],[3771.3,636],[3789.7,419],[3807.4,487],[3823.8,424],[3842.1,407],[3860.6,529],[3879.0,487],[3896.2,385],[3914.6,485],[3933.0,354],[3951.4,408],[3968.5,334],[3986.8,401],[4005.2,456],[4025.8,354],[4044.1,372],[4062.4,465],[4079.0,350],[4097.4,478],[4115.9,288],[4133.3,589],[4151.0,381],[4168.4,395],[4185.0,514],[4203.4,315],[4220.7,591],[4237.6,347],[4254.1,361],[4270.7,633],[4289.1,315],[4306.5,299],[4324.8,728],[4343.2,398],[4361.8,383],[4380.1,687],[4398.4,387],[4414.7,288],[4431.3,400],[4447.8,434],[4459.4,13],[4465.2,1022],[4466.0,1024],[4466.4,1023],[4466.8,554],[4481.9,747],[4498.8,683],[4515.5,688],[4533.6,716],[4552.0,635],[4569.4,685],[4586.8,501],[4605.1,405],[4623.5,423],[4642.0,537],[4660.3,375],[4678.7,458],[4697.2,573],[4715.6,492],[4734.1,411],[4752.7,443],[4771.5,412],[4789.4,227],[4808.0,362],[4826.8,295],[4845.7,344],[4863.8,263],[4881.2,263],[4900.0,263],[4936.9,263],[4954.7,263],[4973.1,263],[4991.4,263],[5009.8,263],[5031.5,263],[5049.4,263],[5067.8,263],[5086.2,263],[5104.6,263],[5121.4,263],[5139.2,263],[5157.6,263],[5175.9,263],[5193.5,263],[5212.1,263],[5230.4,263],[5248.6,263],[5267.1,263],[5285.5,263],[5304.2,263],[5322.5,263],[5339.2,263],[5357.4,263],[5393.9,263],[5410.5,263],[5426.7,263],[5463.2,263],[5499.0,263],[5517.2,263],[5535.6,263],[5663.5,195],[12390.3,32],[12390.8,159]],"promptSentAtMs":1031.8} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.txt b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.txt deleted file mode 100644 index 73555e5e14ac..000000000000 --- a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-agent-row.txt +++ /dev/null @@ -1 +0,0 @@ -[?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[?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]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "What is the tech stack of this project?" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/Users/uuuuu/orca-...xxxxxx/capture/workshift+tab agentsctrl+p commands2.0.21]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l…/uuuuu/xxxxxxx/xxxxxxxx/capture/work [?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃[Pasted ~32 lines]  ┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l]0;OpenCode[?2026h[?25l┃ 2. filler line standing in for the dispatch preamble. ┃ 3. filler line standing in for the dispatch preamble. ┃ 4. filler line standing in for the dispatch preamble. ┃ 5. filler line standing in for the dispatch preamble. ┃ 6. filler line standing in for the dispatch preamble. ┃ 7. filler line standing in for the dispatch preamble. ┃ 8. filler line standing in for the dispatch preamble. ┃ 9. filler line standing in for the dispatch preamble. ┃ 10. filler line standing in for the dispatch preamble. ┃ 11. filler line standing in for the dispatch preamble. ┃ 12. filler line standing in for the dispatch preamble. ┃ 13. filler line standing in for the dispatch preamble. ┃ 14. filler line standing in for the dispatch preamble. ┃ 15. filler line standing in for the dispatch preamble. ┃ 16. filler line standing in for the dispatch preamble. ┃ 17. filler line standing in for the dispatch preamble. ┃ 18. filler line standing in for the dispatch preamble. ┃ 19. filler line standing in for the dispatch preamble. ┃ 20. filler line standing in for the dispatch preamble. ┃ 21. filler line standing in for the dispatch preamble. ┃ 22. filler line standing in for the dispatch preamble. ┃ 23. filler line standing in for the dispatch preamble. ┃ 24. filler line standing in for the dispatch preamble. ┃ 25. filler line standing in for the dispatch preamble. ┃ 26. filler line standing in for the dispatch preamble. ┃ 27. filler line standing in for the dispatch preamble. ┃ 28. filler line standing in for the dispatch preamble. ┃ 29. filler line standing in for the dispatch preamble. ┃ 30. filler line standing in for the dispatch preamble. ┃ Task: reply with the single word ORCAPING and nothing else.  ┃ ┃ ┃ ┃ ┃ Build · Fledge Alpha Free OpenCode Zen ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/Users/uuuuu/xxxxxxx/xxxxxxxx/capture/workshift+tab agentsctrl+p commands[?25h[?2026l[?2026h[?25l ⠋ New session  +  23456789. filer line standing in for the dispatch preamble. 012345678190123456782930. filler line sanding inf the dispatch preamble. Task: reply with the single word ORCAPING and nothing else. ┃ [?25h[?2026l[?2026h[?25l ⠋ New session [?25h[?2026l]0;OpenCode[?2026h[?25l ⠋ New session [?25h[?2026l[?2026h[?25l ⠋ New session ┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃┃ ■⬝⬝⬝⬝⬝⬝⬝ esc interrup [?25h[?2026l[?2026h[?25l ⠋ New session [?25h[?2026l[?2026h[?25l⠙[?25h[?2026l[?2026h[?25l■■⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l■■■⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠹[?25h[?2026l[?2026h[?25l■■■■⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l [?25h[?2026l[?2026h[?25l■■■■■⬝⬝⬝[?25h[?2026l[?2026h[?25l⠸ New session [?25h[?2026l[?2026h[?25l ■■■■■■⬝⬝[?25h[?2026l[?2026h[?25l⠸[?25h[?2026l[?2026h[?25l ⠸ New session ⬝■■■■■■⬝[?25h[?2026l[?2026h[?25l ⠸[?25h[?2026l[?2026h[?25l⠼ New session [?25h[?2026l[?2026h[?25l ⠼⬝⬝■■■■■■[?25h[?2026l[?2026h[?25l ⠼New session [?25h[?2026l[?2026h[?25l⠼ New session [?25h[?2026l[?2026h[?25l⠴ ew session [?25h[?2026l[?2026h[?25l ⠴ N⬝⬝⬝■■■■■[?25h[?2026l[?2026h[?25l ⠴ ew session [?25h[?2026l[?2026h[?25l Nw session ⬝⬝⬝⬝■■■■[?25h[?2026l[?2026h[?25l ⠦Ne[?25h[?2026l[?2026h[?25l New session ⬝⬝⬝⬝⬝■■■[?25h[?2026l[?2026h[?25l ⠦ New[?25h[?2026l[?2026h[?25lNew session [?25h[?2026l[?2026h[?25l ⠦ New ⬝⬝⬝⬝⬝⬝■■[?25h[?2026l[?2026h[?25l⠧ewsession [?25h[?2026l[?2026h[?25l ew ⬝⬝⬝⬝⬝⬝⬝■[?25h[?2026l[?2026h[?25l ⠧ New session [?25h[?2026l[?2026h[?25l⠧w s⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠇ se[?25h[?2026l[?2026h[?25l se⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠇ New sesion [?25h[?2026l[?2026h[?25l ⠇ses[?25h[?2026l[?2026h[?25l⠇Nses⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠏ ess[?25h[?2026l[?2026h[?25l⠏eess⬝⬝⬝⬝⬝⬝■■[?25h[?2026l[?2026h[?25l ⠏ssi[?25h[?2026l[?2026h[?25l⠏ Nssi⬝⬝⬝⬝⬝■■■[?25h[?2026l[?2026h[?25l ⠋wsi[?25h[?2026l[?2026h[?25l ⠋ Nesio[?25h[?2026l[?2026h[?25l⠋ sio⬝⬝⬝⬝■■■■[?25h[?2026l[?2026h[?25l ⠋ Newion[?25h[?2026l[?2026h[?25l⠋ ion⬝⬝⬝■■■■■[?25h[?2026l[?2026h[?25l ⠙Neson [?25h[?2026l[?2026h[?25l⠙ w on ⬝⬝■■■■■■[?25h[?2026l[?2026h[?25l ⠙Neeon  [?25h[?2026l[?2026h[?25l⠙ Nw sn  ⬝■■■■■■⬝[?25h[?2026l[?2026h[?25l ⠹esn  [?25h[?2026l[?2026h[?25l Nw se   [?25h[?2026l[?2026h[?25l ⠹ew   ■■■■■■⬝⬝[?25h[?2026l[?2026h[?25l⠹ N ss   [?25h[?2026l[?2026h[?25l⠹ewes   [?25h[?2026l[?2026h[?25l⠸ Ne si   [?25h[?2026l[?2026h[?25l ⠸wess   ■■■■■⬝⬝⬝[?25h[?2026l[?2026h[?25l Ne so   [?25h[?2026l[?2026h[?25l⠸w esi   ■■■■⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠼ess   [?25h[?2026l[?2026h[?25lNw esn   [?25h[?2026l[?2026h[?25l⠼ sesio   ■■■⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25lew sss    [?25h[?2026l[?2026h[?25lNesion   ■■⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠴w ss    [?25h[?2026l[?2026h[?25leesion    ■⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l sesi   [?25h[?2026l[?2026h[?25lsson    ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠦wi     [?25h[?2026l[?2026h[?25lsesson    [?25h[?2026l[?2026h[?25l io      ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25lessn    [?25h[?2026l[?2026h[?25lio       ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠧sssin    [?25h[?2026l[?2026h[?25lo     ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25len       [?25h[?2026l[?2026h[?25lsion      [?25h[?2026l[?2026h[?25ls       ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠇ion      [?25h[?2026l[?2026h[?25l         ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25lson     [?25h[?2026l[?2026h[?25l       ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠏i        [?25h[?2026l[?2026h[?25ln         ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l o       [?25h[?2026l[?2026h[?25l         [?25h[?2026l[?2026h[?25l         ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠋n        [?25h[?2026l[?2026h[?25l      ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠋        [?25h[?2026l[?2026h[?25l ⠋         ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠙      [?25h[?2026l[?2026h[?25l ⠙       ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠙     [?25h[?2026l[?2026h[?25l ⠙     [?25h[?2026l[?2026h[?25l⠙       ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠹       [?25h[?2026l[?2026h[?25l⠹ N       ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠹ N     [?25h[?2026l[?2026h[?25l⠹ N      ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠸ Ne    [?25h[?2026l[?2026h[?25l Ne    [?25h[?2026l[?2026h[?25l Ne       ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25lNew      [?25h[?2026l[?2026h[?25lNew    ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠼ew        [?25h[?2026l[?2026h[?25lew      ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l w    [?25h[?2026l[?2026h[?25lw s    [?25h[?2026l[?2026h[?25l⠼w s      ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠴ se    [?25h[?2026l[?2026h[?25l se    ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠴ ses    [?25h[?2026l[?2026h[?25l ⠴ses     ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠦Nses [?25h[?2026l[?2026h[?25l ⠦ ess   ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠦eess    [?25h[?2026l[?2026h[?25l ⠦ssi  [?25h[?2026l[?2026h[?25l⠦ Nssi   ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠧wsio [?25h[?2026l[?2026h[?25l ⠧ Nesio  ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠧ sio  [?25h[?2026l[?2026h[?25l ⠧ Newion ■⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠇ ion  [?25h[?2026l[?2026h[?25l ⠇Nson [?25h[?2026l[?2026h[?25l⠇ ew on  ■■⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠇Neeon  [?25h[?2026l[?2026h[?25l⠇ Nw sn  ■■■⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠏esn   [?25h[?2026l[?2026h[?25l Nw e   ■■■■⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠏es   [?25h[?2026l[?2026h[?25l ⠏ Nw s    [?25h[?2026l[?2026h[?25l⠏ewses   ■■■■■⬝⬝⬝[?25h[?2026l[?2026h[?25l⠋ Ne si   [?25h[?2026l[?2026h[?25l ⠋wess   ■■■■■■⬝⬝[?25h[?2026l[?2026h[?25l Ne so   [?25h[?2026l[?2026h[?25l⠋w es   ⬝■■■■■■⬝[?25h[?2026l[?2026h[?25l⠙essi   [?25h[?2026l[?2026h[?25lNw esn  [?25h[?2026l[?2026h[?25l⠙ sesio   ⬝⬝■■■■■■[?25h[?2026l[?2026h[?25lew s    [?25h[?2026l[?2026h[?25lNsesion   [?25h[?2026l[?2026h[?25l⠹w sss   [?25h[?2026l[?2026h[?25lei    45678910. filer line standing in for the dispatch preamble.11. filer line standing in for the dispatch preamble.2345678920212345678930Task: reply with he singlewd ORCAPING and nothing else.    ⠋ Thinking ⬝⬝⬝■■■■■[?25h[?2026l[?2026h[?25lesson    [?25h[?2026l[?2026h[?25l sesio    [?25h[?2026l[?2026h[?25lwsin     ⬝⬝⬝⬝■■■■[?25h[?2026l[?2026h[?25l⠸seso    [?25h[?2026l[?2026h[?25l sin      ⠙[?25h[?2026l[?2026h[?25lso   ⬝⬝⬝⬝⬝■■■[?25h[?2026l[?2026h[?25lesin     [?25h[?2026l[?2026h[?25lson      ⬝⬝⬝⬝⬝⬝■■[?25h[?2026l[?2026h[?25l⠼ssio      [?25h[?2026l[?2026h[?25len      ⠹[?25h[?2026l[?2026h[?25lsio      ⬝⬝⬝⬝⬝⬝⬝■[?25h[?2026l[?2026h[?25ln        [?25h[?2026l[?2026h[?25lsio     ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠴n      [?25h[?2026l[?2026h[?25ls        ⠸[?25h[?2026l[?2026h[?25lon        ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25li        [?25h[?2026l[?2026h[?25ln       ⬝⬝⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠦o         [?25h[?2026l[?2026h[?25l        ⠼[?25h[?2026l[?2026h[?25l         ⬝⬝⬝⬝⬝⬝■■[?25h[?2026l[?2026h[?25ln      [?25h[?2026l[?2026h[?25l           ⬝⬝⬝⬝⬝■■■[?25h[?2026l[?2026h[?25l ⠧        [?25h[?2026l[?2026h[?25l ⠧       ⠴[?25h[?2026l[?2026h[?25l ⠧     ⬝⬝⬝⬝■■■■[?25h[?2026l[?2026h[?25l ⠧      [?25h[?2026l[?2026h[?25l ⠧      ⬝⬝⬝■■■■■[?25h[?2026l[?2026h[?25l ⠇       [?25h[?2026l[?2026h[?25l ⠇      ⠦[?25h[?2026l[?2026h[?25l ⠇      ⬝⬝■■■■■■[?25h[?2026l[?2026h[?25l⠇       [?25h[?2026l[?2026h[?25l⠇ N    [?25h[?2026l[?2026h[?25l⠇ N      ⬝■■■■■■⬝[?25h[?2026l[?2026h[?25l⠏ N       [?25h[?2026l[?2026h[?25l Ne     ⠧[?25h[?2026l[?2026h[?25l Ne      ■■■■■■⬝⬝[?25h[?2026l[?2026h[?25lNew      [?25h[?2026l[?2026h[?25lNew   [?25h[?2026l[?2026h[?25l⠋New     [?25h[?2026l[?2026h[?25lew       ⠇[?25h[?2026l]0;OpenCode[?2026h[?25l • New session       8910. filer line standing in for the dispatch preamble.11. filer line standing in for the dispatch preamble.12. filer line standing in for the dispatch preamble.13. filer line standing in for the dispatch preamble.4567892021222345678930Task: reply with he singlewd ORCAPING and nothing else.    + Thought · 848ms   Build · Fledge Alpha Free · 3.3s · 40.2 tok/s/Users/uuuuu/xxxxxxx/sxxxxxx/capture/work 7.1K (1%)[?25h[?2026l[?2026h[?25l • New session          [?25h[?2026l[?2026h[?25l • New session        [?25h[?2026l[?2026h[?25l • New session        ORCAPING[?25h[?2026l[?2026h[?25l • New session         [?25h[?2026l[?2026h[?25l • New session        [?25h[?2026l[?2026h[?25l • New session        [?25h[?2026l[?2026h[?25l • session    [?25h[?2026l[?2026h[?25l •Nees    [?25h[?2026l[?2026h[?25l • Nw sss  [?25h[?2026l[?2026h[?25l • New ssion     [?25h[?2026l[?2026h[?25l • New sesi  [?25h[?2026l[?2026h[?25l • New sss   [?25h[?2026l[?2026h[?25l • New esion   [?25h[?2026l[?2026h[?25l • New session  [?25h[?2026l[?2026h[?25l • New sessin [?25h[?2026l[?2026h[?25l • New sesson  [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l Nw sessi [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session  [?25h[?2026l[?2026h[?25l • New session  [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l   [?25h[?2026l]0;OC | Orca readiness capture[?2026h[?25lOrca readiess capture[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.meta.json deleted file mode 100644 index 0e937e23c17c..000000000000 --- a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.meta.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "capturedAt": "2026-10-04T05:15:47.772691Z", - "platform": "darwin", - "command": ["opencode", "--standalone"], - "cols": 120, - "rows": 40, - "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 120x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane), env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs. Why this run: negative control. One of four simultaneous cold --standalone starts; the same paste was written at the box's show-cursor (the old signal) and Enter 23 ms after the paste (promptSentAtMs), 320 ms before the agent/model row. OpenCode dropped the Enter: the brief stayed in the composer as [Pasted ~32 lines] until recording stopped.", - "exitCode": null -} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.timing.json deleted file mode 100644 index ae9945f14864..000000000000 --- a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.timing.json +++ /dev/null @@ -1 +0,0 @@ -{"recordedUntilMs":15670.2,"chunks":[[441.6,317],[446.3,80],[459.6,1024],[460.1,1024],[460.4,1024],[460.7,1024],[460.9,1024],[461.2,1024],[463.9,353],[465.2,24],[465.4,48],[465.4,108],[471.2,13],[476.5,1024],[477.2,1024],[478.2,1024],[479.3,1024],[480.1,1024],[480.3,1024],[480.6,353],[545.8,934],[547.1,938],[547.8,835],[575.2,349],[907.3,476],[926.2,476],[943.1,481],[958.6,496],[975.9,326],[994.0,326],[1012.7,326],[1030.8,326],[1047.9,326]],"promptSentAtMs":586.3} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.txt b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.txt deleted file mode 100644 index dbf77751ae39..000000000000 --- a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-enter-at-box.txt +++ /dev/null @@ -1 +0,0 @@ -[?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[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h]0;OpenCode[?2026h[?25l                                        [?2026l[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix broken tests" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/Users/uuuuu/orca-...xxxxxx/capture/workshift+tab agentsctrl+p commands2.0.21]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l[Pasted ~32 lines]  …/uuuuu/xxxxxxx/xxxxxxxx/capture/work [?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·Fledge Alpha FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.meta.json new file mode 100644 index 000000000000..6f3ccfd7781e --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-03T22:30:39Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 160, + "rows": 48, + "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 160x48, alternate screen. NOT the timed Python driver: a node-pty probe script (each read kept with its ms since spawn; it answers no terminal queries) used while investigating this bug. Recorded at natural load: other sessions on the machine had the load average at 4.39 / 30.38 / 44.19 (read 11 minutes later; this capture ran alone). The user's own login and plugins in place, OPENCODE_DISABLE_AUTOUPDATE=1, no ORCA_* vars. Orca's worker-start timing was replayed: an 82-line brief was bracketed-pasted at the box's show-cursor (3157 ms) and Enter sent 502 ms later (3659 ms, promptSentAtMs). The agent/model row only painted at 4141 ms; OpenCode dropped the Enter and the brief stayed in the composer as [Pasted ~82 lines]; OpenCode painted nothing more from 4164 ms until the probe stopped it at 40 s (its exit frames are cut). Scrubbed with same-length placeholders: checkout path and ticket id, and two earlier session titles in the tab strip. .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-21-timed-natural-load-enter-dropped.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.timing.json new file mode 100644 index 000000000000..4d0599cca3e5 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":40000,"promptSentAtMs":3659,"chunks":[[1766,280],[1768,37],[2810,1024],[2811,1024],[2811,1024],[2812,1024],[2812,1024],[2813,1024],[2814,1024],[2815,1024],[2815,1024],[2816,497],[2824,13],[3153,1024],[3153,1024],[3154,1024],[3155,952],[3156,928],[3157,886],[3157,422],[3247,200],[3358,297],[3657,1016],[3657,1018],[3658,1018],[3658,1016],[3659,558],[3781,1016],[3783,1016],[3784,1016],[3785,771],[3790,1012],[3791,1010],[3791,422],[3825,1012],[3826,1010],[3826,598],[3830,747],[4141,1021],[4164,326]]} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.txt b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.txt new file mode 100644 index 000000000000..1a5ee72fa090 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-natural-load-enter-dropped.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[?2026h[?25l                                                [?2026l]0;OpenCode[?2026h[?25lWhy dWe neWe neWe neSubagWe neXXXXWhy dWhy dWhy dXXXXX11RET63NET33SUR35NORFIVEG + New session   ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix broken tests" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀~/orca/workspaces/...xxx-xxxxxxxx-xxxxxxshift+tab agentsctrl+p commands2.0.21]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l…workspa…/orca/xx-x[?25h[?2026l[?2026h[?25l • FIV[?25h[?2026l[?2026h[?25l • Why  • We n • We n • We n • Suba • We n • PONG  • Why  • Why  • Why  • NORE • 11RE • 63NE • 33SU • FIVExxxxxxxx-pe…[?25h[?2026l[?2026h[?25l • Why • We  • We  • We  • Sub • We  • PON • Why • Why • Why • NOR • 11R • 63N • 33S • FIV[?25h[?2026l[?2026h[?25l • h • e • e • e • u • e • O • h • h • h • O • 1 • 3 • 3 • I[?25h[?2026l[?2026h[?25l • W • W • W • W • S • W • P • W • W • W • N • 1 • 6 • 3 • F[Pasted ~82 lines]  [?25h[?2026l[?2026h[?25lWWWWSWPWWWN163F[?25h[?2026l[?2026h[?25lheeeueOhhhO133I·DeepSeek V4.1 FlashOpenCode Go·high[?25h[?2026l[?2026h[?25l┃┃┃┃Build╹[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-agent-row@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-14-timed-cold-standalone@agent.json similarity index 53% rename from src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-agent-row@agent.json rename to src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-14-timed-cold-standalone@agent.json index 6fdc793fbf23..57316948a724 100644 --- a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-agent-row@agent.json +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-14-timed-cold-standalone@agent.json @@ -3,13 +3,11 @@ "observations": { "clocked": [ "0-14: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", - "15-289: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", - "290-291: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + "15-29: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" ], "clockless": [ "0-14: verdict=pending:closed wait=pending", - "15-289: verdict=ready-weak wait=ready@start", - "290-291: verdict=ready-strong wait=ready@start" + "15-29: verdict=ready-weak wait=ready@start" ] } } diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-agent-row@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-14-timed-cold-standalone@unknown.json similarity index 53% rename from src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-agent-row@unknown.json rename to src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-14-timed-cold-standalone@unknown.json index 38207d3d3d4d..8e14739f55cb 100644 --- a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-agent-row@unknown.json +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-14-timed-cold-standalone@unknown.json @@ -3,13 +3,11 @@ "observations": { "clocked": [ "0-14: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", - "15-289: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", - "290-291: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + "15-29: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" ], "clockless": [ "0-14: verdict=pending:open wait=ready@poll", - "15-289: verdict=ready-weak wait=ready@start", - "290-291: verdict=ready-strong wait=ready@start" + "15-29: verdict=ready-weak wait=ready@start" ] } } diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-busy-standalone@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-busy-standalone@agent.json deleted file mode 100644 index c0e1ce874c58..000000000000 --- a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-busy-standalone@agent.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk", - "observations": { - "clocked": [ - "0-16: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", - "17-30: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" - ], - "clockless": [ - "0-16: verdict=pending:closed wait=pending", - "17-30: verdict=ready-weak wait=ready@start" - ] - } -} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-busy-standalone@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-busy-standalone@unknown.json deleted file mode 100644 index c6eff2391d36..000000000000 --- a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-busy-standalone@unknown.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk", - "observations": { - "clocked": [ - "0-16: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", - "17-30: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" - ], - "clockless": [ - "0-16: verdict=pending:open wait=ready@poll", - "17-30: verdict=ready-weak wait=ready@start" - ] - } -} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-after-agent-row@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-after-agent-row@agent.json new file mode 100644 index 000000000000..c06dea3a4aea --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-after-agent-row@agent.json @@ -0,0 +1,15 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-8: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "9-208: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "209-280: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-8: verdict=pending:closed wait=pending", + "9-208: verdict=ready-weak wait=ready@start", + "209-280: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-after-agent-row@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-after-agent-row@unknown.json new file mode 100644 index 000000000000..25bb42e759fa --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-after-agent-row@unknown.json @@ -0,0 +1,15 @@ +{ + "description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-8: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "9-208: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "209-280: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-8: verdict=pending:open wait=ready@poll", + "9-208: verdict=ready-weak wait=ready@start", + "209-280: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-box@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-natural-load-enter-dropped@agent.json similarity index 59% rename from src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-box@agent.json rename to src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-natural-load-enter-dropped@agent.json index 35730c38e833..efeb17cc774b 100644 --- a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-box@agent.json +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-natural-load-enter-dropped@agent.json @@ -1,13 +1,13 @@ { - "description": "opencode2 recording at 120x40 replayed on the agent pane, one entry per chunk", + "description": "opencode2 recording at 160x48 replayed on the agent pane, one entry per chunk", "observations": { "clocked": [ "0-11: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", - "12-32: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + "12-39: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" ], "clockless": [ "0-11: verdict=pending:closed wait=pending", - "12-32: verdict=ready-weak wait=ready@start" + "12-39: verdict=ready-weak wait=ready@start" ] } } diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-box@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-natural-load-enter-dropped@unknown.json similarity index 59% rename from src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-box@unknown.json rename to src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-natural-load-enter-dropped@unknown.json index d6725c5512f0..32e7ca23e8ea 100644 --- a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-enter-at-box@unknown.json +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-natural-load-enter-dropped@unknown.json @@ -1,13 +1,13 @@ { - "description": "opencode2 recording at 120x40 replayed on the unknown pane, one entry per chunk", + "description": "opencode2 recording at 160x48 replayed on the unknown pane, one entry per chunk", "observations": { "clocked": [ "0-11: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", - "12-32: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" + "12-39: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start" ], "clockless": [ "0-11: verdict=pending:open wait=ready@poll", - "12-32: verdict=ready-weak wait=ready@start" + "12-39: verdict=ready-weak wait=ready@start" ] } } diff --git a/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts index f4cc73e14d31..8d7039daa5cf 100644 --- a/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts +++ b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts @@ -43,7 +43,9 @@ const OPENCODE_TIMED = [ 'opencode-2-0-18-timed-boot-hidden-pane', 'opencode-2-0-21-timed-cold-standalone', 'opencode-2-0-21-timed-cold-standalone-hidden-pane', - 'opencode-2-0-21-timed-busy-standalone', + 'opencode-2-0-21-timed-natural-load-enter-dropped', + 'opencode-2-0-21-timed-enter-after-agent-row', + 'opencode-2-0-14-timed-cold-standalone', 'opencode-cmd-2-0-21-timed-warm-server' ] diff --git a/src/main/runtime/launched-agent-composer-readiness.test.ts b/src/main/runtime/launched-agent-composer-readiness.test.ts index 6baac6d9be77..b0db510c493c 100644 --- a/src/main/runtime/launched-agent-composer-readiness.test.ts +++ b/src/main/runtime/launched-agent-composer-readiness.test.ts @@ -149,7 +149,10 @@ describe('every capture a row cites proves its input-box marker', () => { let rowRead = -1 let read = 0 for await (const frame of replayTranscript(chunks, meta.cols, meta.rows)) { - const box = frame.screenLines.findIndex((line) => line.includes(OPENCODE_PLACEHOLDER)) + // A brief pasted before the row replaces the placeholder in the box. + const box = frame.screenLines.findIndex( + (line) => line.includes(OPENCODE_PLACEHOLDER) || line.includes('[Pasted ~') + ) if (box !== -1 && boxRead === -1) { boxRead = read } diff --git a/src/main/runtime/opencode-agent-row-enter-transcript.test.ts b/src/main/runtime/opencode-agent-row-enter-transcript.test.ts index f14958603a60..b580cbf67da2 100644 --- a/src/main/runtime/opencode-agent-row-enter-transcript.test.ts +++ b/src/main/runtime/opencode-agent-row-enter-transcript.test.ts @@ -1,53 +1,63 @@ /** - * Proof that the agent-row signal is the moment OpenCode 2 can submit. Both captures are cold - * `--standalone` starts launched four at a time; each pasted the same brief and pressed Enter - * (promptSentAtMs) — one once the agent row was painted, one at the box's show-cursor before it. + * Proof that the agent-row signal is the moment OpenCode 2 can submit, from two natural cold + * `--standalone` starts that replayed Orca's worker-start timing (paste at readiness, Enter 500 ms + * plus paste time later, at promptSentAtMs): one pasted on the agent row, one on the box's cursor + * while a loaded machine kept the agent list from arriving. */ 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 { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { readTimedRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness' const HELD_PASTE = '[Pasted ~' -const BRIEF_TASK_LINE = 'Task: reply with the single word' +const AGENT_ROW_SIGNAL = TUI_AGENT_CONFIG.opencode2.draftPasteReadySignal! +// The box's show-cursor, the rule OpenCode used before the agent-row signal. +const BOX_CURSOR_SIGNAL: DraftPasteReadySignal = 'render-cursor-after-bracketed-paste' -async function replayEnter(name: string) { - const fixture = readTimedRuntimeFixture(name) +function readyAtMs(name: string, signal: DraftPasteReadySignal): number { + const { chunks, times } = readTimedRuntimeFixture(name) + const scanner = createDraftPasteReadyScanner(signal) + return times[chunks.findIndex((chunk) => scanner.observe(chunk).ready)] +} + +async function finalScreen(name: string): Promise { const meta: { cols: number; rows: number } = JSON.parse( readFileSync(join(__dirname, '__fixtures__', `${name}.meta.json`), 'utf8') ) - const scanner = createDraftPasteReadyScanner(TUI_AGENT_CONFIG.opencode2.draftPasteReadySignal!) - const readyRead = fixture.chunks.findIndex((chunk) => scanner.observe(chunk).ready) let lastScreen: string[] = [] - for await (const frame of replayTranscript(fixture.chunks, meta.cols, meta.rows)) { + for await (const frame of replayTranscript( + readTimedRuntimeFixture(name).chunks, + meta.cols, + meta.rows + )) { lastScreen = frame.screenLines } - return { - readyAtMs: fixture.times[readyRead], - enterAtMs: fixture.promptSentAtMs!, - lastScreen: lastScreen.join('\n') - } + return lastScreen.join('\n') } -describe('an Enter OpenCode 2 receives after the agent-row signal submits', () => { - it('submits the brief when Enter follows the signal', async () => { - const { readyAtMs, enterAtMs, lastScreen } = await replayEnter( - 'opencode-2-0-21-timed-enter-at-agent-row' - ) - expect(readyAtMs).toBeLessThanOrEqual(enterAtMs) - expect(lastScreen).not.toContain(HELD_PASTE) - expect(lastScreen).toContain(BRIEF_TASK_LINE) +describe('OpenCode 2 submits an Enter sent after the agent row and drops one sent before it', () => { + it('submits the brief when Orca pastes on the agent row', async () => { + const name = 'opencode-2-0-21-timed-enter-after-agent-row' + const { promptSentAtMs } = readTimedRuntimeFixture(name) + expect(readyAtMs(name, AGENT_ROW_SIGNAL)).toBeLessThan(promptSentAtMs!) + const screen = await finalScreen(name) + expect(screen).not.toContain(HELD_PASTE) + expect(screen).toContain('Task: reply with the single word') }) - it('leaves the brief in the composer when Enter came at the box, before the signal', async () => { - const { readyAtMs, enterAtMs, lastScreen } = await replayEnter( - 'opencode-2-0-21-timed-enter-at-box' - ) - expect(enterAtMs).toBeLessThan(readyAtMs) - expect(lastScreen).toContain(HELD_PASTE) - expect(lastScreen).not.toContain(BRIEF_TASK_LINE) + it('drops the Enter of a brief pasted on the box cursor while the agent list is late', async () => { + const name = 'opencode-2-0-21-timed-natural-load-enter-dropped' + const { promptSentAtMs } = readTimedRuntimeFixture(name) + const boxCursorAt = readyAtMs(name, BOX_CURSOR_SIGNAL) + const agentRowAt = readyAtMs(name, AGENT_ROW_SIGNAL) + // The old rule fired over half a second before the row, so Orca's Enter landed in the gap. + expect(agentRowAt - boxCursorAt).toBeGreaterThanOrEqual(500) + expect(promptSentAtMs!).toBeGreaterThan(boxCursorAt) + expect(promptSentAtMs!).toBeLessThan(agentRowAt) + expect(await finalScreen(name)).toContain(HELD_PASTE) }) }) diff --git a/src/main/runtime/opencode-worker-composer-transcript.test.ts b/src/main/runtime/opencode-worker-composer-transcript.test.ts index 844e99b99000..f34794dfdc0f 100644 --- a/src/main/runtime/opencode-worker-composer-transcript.test.ts +++ b/src/main/runtime/opencode-worker-composer-transcript.test.ts @@ -32,7 +32,8 @@ const RUNS: [string, TuiAgent][] = [ ['opencode-2-0-18-timed-boot-hidden-pane', 'opencode2'], ['opencode-2-0-21-timed-cold-standalone', 'opencode2'], ['opencode-2-0-21-timed-cold-standalone-hidden-pane', 'opencode2'], - ['opencode-2-0-21-timed-busy-standalone', 'opencode2'] + ['opencode-2-0-21-timed-natural-load-enter-dropped', 'opencode2'], + ['opencode-2-0-14-timed-cold-standalone', 'opencode2'] ] const OPENCODE_1_RUNS = RUNS.filter(([name]) => name.startsWith('opencode-1-')) const AGENT_ROW_SEPARATOR = '\u00b7' @@ -115,7 +116,7 @@ describe('an OpenCode worker gets its task only once OpenCode can submit it', () it('never settles on the box while a slow agent list leaves the row unpainted', async () => { const { settledAt, boxRead } = await replay( - 'opencode-2-0-21-timed-busy-standalone', + 'opencode-2-0-21-timed-natural-load-enter-dropped', 'opencode2' ) expect(settledAt.composer).toBeGreaterThan(boxRead) 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 7474ccea39c5..1b40f8da5e0f 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 @@ -90,12 +90,12 @@ describe('pty-bound agent draft readiness budget', () => { expect(onUnconfirmedDelivery).not.toHaveBeenCalled() }) - it('flags a blind paste when only the opencode process, not its composer, was seen', async () => { + it('flags a blind paste when only the agent 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. testState.waitForReady.mockResolvedValue(false) testState.inspectProcess.mockResolvedValue({ - foregroundProcess: 'opencode', + foregroundProcess: 'grok', hasChildProcesses: false }) const onUnconfirmedDelivery = vi.fn() @@ -103,7 +103,7 @@ describe('pty-bound agent draft readiness budget', () => { tabId: 'tab-1', ptyId: 'pty-1', content: 'draft', - agent: 'opencode', + agent: 'grok', forcePaste: true, onUnconfirmedDelivery }) @@ -115,6 +115,32 @@ describe('pty-bound agent draft readiness budget', () => { expect(onUnconfirmedDelivery).toHaveBeenCalledTimes(1) }) + it('reports a timeout instead of pasting blind when OpenCode never paints its agent row', async () => { + testState.waitForReady.mockResolvedValue(false) + testState.inspectProcess.mockResolvedValue({ + foregroundProcess: 'opencode', + hasChildProcesses: false + }) + const onTimeout = vi.fn() + const onUnconfirmedDelivery = vi.fn() + const promise = pasteDraftToAgentPtyWhenReady({ + tabId: 'tab-1', + ptyId: 'pty-1', + content: 'draft', + agent: 'opencode', + forcePaste: true, + onTimeout, + onUnconfirmedDelivery + }) + + await vi.advanceTimersByTimeAsync(1000) + + await expect(promise).resolves.toBe(false) + expect(onTimeout).toHaveBeenCalledTimes(1) + expect(onUnconfirmedDelivery).not.toHaveBeenCalled() + expect(testState.sendInput).not.toHaveBeenCalled() + }) + it('keeps the 8s readiness deadline for agents without an agent-specific budget', async () => { const onTimeout = vi.fn() const promise = pasteDraftToAgentPtyWhenReady({ diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index 91cfb42d21fc..23ae87eb3651 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -291,33 +291,32 @@ describe('pasteDraftWhenAgentReady', () => { expect(onTimeout).toHaveBeenCalledTimes(1) }) - it('best-effort pastes for opencode at the hard timeout when its process is running', async () => { - // Why: with no quiet window, the hard-timeout process-ownership check is the - // backstop if show-cursor is somehow missed — same model as Codex. + it('reports a timeout instead of pasting blind when OpenCode never paints its agent row', async () => { + // Why: OpenCode drops an Enter until the row exists, so a blind paste would sit unsent in its + // box. The running process does not change that; the caller gets its timeout notice. testState.inspectRuntimeTerminalProcess.mockResolvedValue({ foregroundProcess: 'opencode', hasChildProcesses: false }) + const onTimeout = vi.fn() const onUnconfirmedDelivery = vi.fn() const promise = pasteDraftWhenAgentReady({ tabId: 'tab-1', content: ISSUE_URL, agent: 'opencode', + onTimeout, onUnconfirmedDelivery }) await flushMicrotasks() - testState.ptyObserver?.(DECSET_BRACKETED_PASTE) + testState.ptyObserver?.(OPENCODE_BOX) await vi.advanceTimersByTimeAsync(20_000) + await flushMicrotasks(5) - await expect(promise).resolves.toBe(true) - expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( - {}, - 'pty-1', - PASTED_ISSUE_URL - ) - // The composer was never observed; the caller must be able to hedge its success notice. - expect(onUnconfirmedDelivery).toHaveBeenCalledTimes(1) + await expect(promise).resolves.toBe(false) + expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() + expect(onTimeout).toHaveBeenCalledTimes(1) + expect(onUnconfirmedDelivery).not.toHaveBeenCalled() }) it('keeps the existing fallback budget for unrelated markerless agents', async () => { diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index feca855338a2..69c619688fee 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -1,7 +1,7 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' 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 { TUI_AGENT_CONFIG, type DraftPasteReadySignal } from '../../../shared/tui-agent-config' import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout' import { useAppStore } from '@/store' import { @@ -43,6 +43,10 @@ export const POST_PASTE_SUBMIT_DELAY_MS = 50 // also stops one slow step from spending the other's budget (STA-3367). const PTY_SPAWN_TIMEOUT_MS = 8000 +// Why: OpenCode drops an Enter until its agent row exists, so a blind paste would only leave the +// draft sitting unsent in its input box; these signals report the timeout instead. +const NO_BLIND_PASTE_SIGNALS: ReadonlySet = new Set(['opencode-agent-row']) + export function getSettingsForAgentTabRuntimeOwner( tabId: string ): Pick | null | undefined { @@ -119,9 +123,10 @@ export async function pasteDraftWhenAgentReady(args: { // this sidecar subscription attaches. If process/title inspection says the // launched agent owns the PTY, fall back to a best-effort paste instead of // silently dropping generated prompts. - const fallbackReady = agentConfig - ? await waitForAgentReady(tabId, agentConfig.expectedProcess, { timeoutMs: 1000 }) - : { ready: false } + const fallbackReady = + agentConfig && !NO_BLIND_PASTE_SIGNALS.has(readySignal) + ? await waitForAgentReady(tabId, agentConfig.expectedProcess, { timeoutMs: 1000 }) + : { ready: false } if (!fallbackReady.ready) { onTimeout?.() return false @@ -176,9 +181,10 @@ export async function pasteDraftToAgentPtyWhenReady(args: { const budget = resolveDraftPasteReadyTimeoutMs(agent, timeoutMs) const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal, settings) if (!ready) { - const fallbackReady = agentConfig - ? await waitForExpectedAgentOnPty(ptyId, agentConfig.expectedProcess, 1000, settings) - : false + const fallbackReady = + agentConfig && !NO_BLIND_PASTE_SIGNALS.has(readySignal) + ? await waitForExpectedAgentOnPty(ptyId, agentConfig.expectedProcess, 1000, settings) + : false if (!fallbackReady) { onTimeout?.() return false diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index e9ccccf72eb6..a2ee87c02ef5 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -120,7 +120,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { 'opencode-2-0-18-timed-boot-hidden-pane', 'opencode-2-0-21-timed-cold-standalone', 'opencode-2-0-21-timed-cold-standalone-hidden-pane', - 'opencode-2-0-21-timed-busy-standalone' + 'opencode-2-0-21-timed-natural-load-enter-dropped', + 'opencode-2-0-14-timed-cold-standalone' ] }, 'mimo-code': { From a5bb924d9b08a14bd3c74d08657109680978b74a Mon Sep 17 00:00:00 2001 From: m4air Date: Sat, 3 Oct 2026 23:02:25 -0700 Subject: [PATCH 4/9] fix(opencode): type the single-signal predicate against the public signals Co-Authored-By: Claude --- src/shared/draft-paste-ready-scanner.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index a9161887d687..06fc76e2a834 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -51,9 +51,8 @@ const ALL_OF_SIGNALS: Partial - | 'opencode-agent-row-separator' +type CompositeSignal = 'opencode-agent-row' +type SingleSignal = Exclude | 'opencode-agent-row-separator' const DRAFT_PASTE_READY_SIGNALS: Record = { 'codex-composer-prompt': { @@ -204,7 +203,9 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) } } -function isSingleSignal(signal: DraftPasteReadySignal): signal is SingleSignal { +function isSingleSignal( + signal: DraftPasteReadySignal +): signal is Exclude { return signal in DRAFT_PASTE_READY_SIGNALS } From 851fda9454a82e59fbbe6ade2c574506a5bffc61 Mon Sep 17 00:00:00 2001 From: m4air Date: Sun, 4 Oct 2026 01:13:31 -0700 Subject: [PATCH 5/9] fix(opencode): read the agent row by its place on screen and wait for it only before Enter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The agent-row signal now keys on structure: the `·` must sit on the line directly above the input box's bottom-left corner, so a `·` in the footer path or a session tab title no longer counts. Leaving the alternate screen or turning bracketed paste off withdraws what each part established. - Only deliveries that press Enter wait for the row (new `submitPasteReadySignal`); drafts keep the box-cursor rule, as on main. Worker start always submits. - When the box cursor has shown but the row never comes (OpenCode 2 drops the agent below 44 columns), both waiters proceed after a 5 s grace, as main did. A natural 40-column capture proves that start submits. - The renderer's blind-paste fallback is back to main's behaviour. Co-Authored-By: Claude --- ...pencode-2-0-21-timed-narrow-pane.meta.json | 9 + ...ncode-2-0-21-timed-narrow-pane.timing.json | 1 + .../opencode-2-0-21-timed-narrow-pane.txt | 1 + ...encode-2-0-21-timed-narrow-pane@agent.json | 15 ++ ...code-2-0-21-timed-narrow-pane@unknown.json | 15 ++ ...draft-paste-ready-scanner-chunking.test.ts | 77 +++++---- .../launched-agent-composer-readiness.test.ts | 18 +- ...pencode-agent-row-enter-transcript.test.ts | 14 +- ...ode-agent-row-structure-transcript.test.ts | 87 ++++++++++ ...pencode-worker-composer-transcript.test.ts | 64 +++++-- .../orca-runtime-activate-managed-worktree.ts | 2 +- ...worktree-setup-and-startup-part-03.spec.ts | 7 +- ...runtime-worktree-startup-readiness.test.ts | 95 +++++++++++ .../runtime-worktree-startup-readiness.ts | 20 ++- .../src/lib/agent-draft-readiness.test.ts | 36 ++++ src/renderer/src/lib/agent-draft-readiness.ts | 12 +- ...agent-paste-draft-readiness-budget.test.ts | 58 +++---- .../src/lib/agent-paste-draft.test.ts | 38 ++--- src/renderer/src/lib/agent-paste-draft.ts | 31 ++-- src/shared/draft-paste-ready-scanner.test.ts | 111 ++++++++++-- src/shared/draft-paste-ready-scanner.ts | 81 +++++---- src/shared/opencode-agent-row-scanner.ts | 159 ++++++++++++++++++ src/shared/tui-agent-config-types.ts | 4 +- src/shared/tui-agent-config.ts | 12 +- src/shared/tui-agent-startup.test.ts | 7 +- 25 files changed, 773 insertions(+), 201 deletions(-) create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.txt create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-narrow-pane@agent.json create mode 100644 src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-narrow-pane@unknown.json create mode 100644 src/main/runtime/opencode-agent-row-structure-transcript.test.ts create mode 100644 src/shared/opencode-agent-row-scanner.ts diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.meta.json new file mode 100644 index 000000000000..0b76be346f90 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-04T06:50:40.510833Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 40, + "rows": 40, + "note": "opencode v2.0.21 (Homebrew opencode-v2) on macOS 26 arm64, 40x40, alternate screen. Recorded with the timed Python PTY driver (each read stamped with ms since spawn; terminal queries answered like Orca's pane). One natural cold start, no other capture running; load average at start 59.44 / 56.29 / 68.90 from other work on the machine. --standalone, env -i with an isolated HOME and XDG dirs (no credentials; OpenCode Zen's free models), OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no ORCA_* vars; version and binary sha256 unchanged before and after. Why this run: below 44 columns OpenCode 2 leaves the agent, and so the ' \u00b7 ' separator, out of the row under its input box, which shows the model alone. The box cursor showed at 11338 ms; the one-line task was bracketed-pasted 5136 ms later, at the agent-row signal's grace, and Enter sent 592 ms after that (promptSentAtMs). OpenCode submitted it: the reply was painted. The only separators are in the reply's footer, never on the row above the box's bottom corner. .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-21-timed-narrow-pane.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.timing.json new file mode 100644 index 000000000000..d5012800d207 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":43535.8,"chunks":[[8942.4,317],[9527.3,1024],[9533.4,1024],[9539.5,1024],[9548.5,377],[9554.7,1024],[9611.7,1024],[9667.3,1024],[9684.9,261],[9742.1,24],[9744.4,1024],[9745.4,1024],[9758.2,1024],[9774.9,273],[9903.5,13],[11327.2,904],[11337.0,966],[11337.9,298],[12105.2,319],[12132.3,319],[12133.9,332],[12205.5,332],[12339.2,332],[12345.6,332],[16906.0,711],[17823.9,526],[18296.1,13],[19345.3,1016],[19359.4,1018],[19380.4,471],[19537.0,451],[19582.4,13],[20156.6,720],[20180.8,259],[20226.8,259],[20440.4,518],[20645.0,259],[20809.6,455],[20883.7,350],[20942.3,453],[21007.1,854],[21033.0,502],[21047.9,320],[21107.7,484],[21113.8,435],[21128.5,466],[21205.7,567],[21279.8,1020],[21339.9,100],[21446.7,1020],[21448.4,492],[21467.8,646],[21525.3,537],[21590.6,609],[21607.0,638],[21656.9,861],[21658.1,354],[21676.7,385],[21750.4,387],[21790.4,635],[21792.8,599],[21809.9,335],[21930.4,517],[21947.1,512],[21954.5,314],[21992.7,365],[21998.1,431],[22198.4,1020],[22201.1,212],[22209.3,399],[22377.1,582],[22398.5,632],[22419.2,588],[22443.5,601],[22480.0,453],[22531.5,417],[22578.2,600],[22607.7,665],[22617.8,567],[22633.6,435],[22655.4,601],[22716.9,587],[22723.5,721],[22871.2,372],[22886.5,681],[22983.1,737],[23002.9,639],[23178.7,609],[23186.5,465],[23210.5,368],[23222.3,348],[23260.7,447],[23266.8,381],[23289.4,315],[23307.7,282],[23324.8,413],[23341.7,314],[23362.2,249],[23468.6,614],[23544.4,531],[23566.4,600],[23973.6,1012],[23974.4,255],[23978.2,656],[24019.9,435],[24022.8,786],[24119.2,550],[24145.2,1006],[24162.9,28],[24218.8,952],[24253.0,661],[24300.5,1010],[24321.7,798],[24618.9,750],[24696.0,13],[25258.6,321],[25314.6,263],[25358.8,526],[25423.5,263],[25483.4,263],[25524.9,263],[25615.5,263],[25626.6,263],[25699.6,263],[25806.1,195],[26082.6,31],[26083.4,158],[26467.6,86]],"promptSentAtMs":17066.1} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.txt b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-pane.txt new file mode 100644 index 000000000000..5185562f8f7b --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-21-timed-narrow-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[?2026h[?25l                                        [?2026l[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l█▀▀█ █▀▀█ █▀▀█ █▀▀▄█ █ █ █ █▀▀▀ █ █▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀ ▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀██  █ █ █ █ █▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "What is the tech sta ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/…/uuuuu/xxxxxxx/xxxxxxxx/capture/work]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l┃┃┃┃Fledge Alpha Free╹[?25h[?2026l[?2026h[?25l┃┃┃┃Fledge Alpha Free╹[?25h[?2026l[?2026h[?25l┃┃┃┃Fledge Alpha Free╹[?25h[?2026l[?2026h[?25l┃┃┃┃Fledge Alpha Free╹[?25h[?2026l[?2026h[?25l┃┃┃┃Fledge Alpha Free╹[?25h[?2026l[?2026h[?25l┃┃┃┃Fledge Alpha Free╹[?25h[?2026l[?2026h[?25lTask: reply with the single word  ok. ┃ Fledge Alpha Free ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/…/uuuuu/xxxxxxx/xxxxxxxx/capture/work[?25h[?2026l[?2026h[?25lAsk anything… "What is the tech sta Fledge Alpha Free╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/…/uuuuu/xxxxxxx/xxxxxxxx/capture/work [?25h[?2026l]0;OpenCode[?2026h[?25l┃ ┃ Task: reply with the single word  ┃ ok. ┃              ┃ ┃ ┃ ┃ Fledge Alpha Free ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/…/uuuuu/xxxxxxx/xxxxxxxx/capture/work[?25h[?2026l[?2026h[?25l  Task: reply with the single word ok.┃ [?25h[?2026l]0;OpenCode[?2026h[?25l ⠋ New session  +   Task: reply with the single word ok.┃ [?25h[?2026l[?2026h[?25l ⠋ New session [?25h[?2026l[?2026h[?25l ⠋ New session [?25h[?2026l[?2026h[?25l ⠙ New session [?25h[?2026l[?2026h[?25l ⠹ New session [?25h[?2026l[?2026h[?25l ⠹ New session [?25h[?2026l[?2026h[?25l ⠸ New session [?25h[?2026l[?2026h[?25l ⠸ Ne se[?25h[?2026l[?2026h[?25l ⠸ Nsesion [?25h[?2026l[?2026h[?25l ⠼ Neessi[?25h[?2026l[?2026h[?25l ⠼ Newsio[?25h[?2026l[?2026h[?25l ⠼ New ion[?25h[?2026l[?2026h[?25l⠴ sion[?25h[?2026l[?2026h[?25l ⠴ New on [?25h[?2026l[?2026h[?25l ⠴Neeon  [?25h[?2026l[?2026h[?25l ⠦ Nw sn  [?25h[?2026l[?2026h[?25l⠦ New ses   [?25h[?2026l[?2026h[?25l ⠧ New sessi   [?25h[?2026l[?2026h[?25l ⠧w ess   [?25h[?2026l[?2026h[?25l⠧ New session    [?25h[?2026l[?2026h[?25l⠇Nw session     [?25h[?2026l[?2026h[?25lew session      [?25h[?2026l[?2026h[?25l⠏ sion      [?25h[?2026l[?2026h[?25lsession        [?25h[?2026l[?2026h[?25l⠋ession         [?25h[?2026l[?2026h[?25lson         [?25h[?2026l[?2026h[?25lin       [?25h[?2026l[?2026h[?25ls        [?25h[?2026l[?2026h[?25l⠙on      [?25h[?2026l[?2026h[?25li         [?25h[?2026l[?2026h[?25l on              [?25h[?2026l[?2026h[?25l ⠹n             [?25h[?2026l[?2026h[?25l         [?25h[?2026l[?2026h[?25l ⠹            [?25h[?2026l[?2026h[?25l ⠸            [?25h[?2026l[?2026h[?25l ⠸      [?25h[?2026l[?2026h[?25l⠸ N      [?25h[?2026l[?2026h[?25l⠸ N        [?25h[?2026l[?2026h[?25l⠸ New            [?25h[?2026l[?2026h[?25l ⠼New s         [?25h[?2026l[?2026h[?25l ⠴ se    [?25h[?2026l[?2026h[?25l⠴  ses        [?25h[?2026l[?2026h[?25l ⠴ Nsess       [?25h[?2026l[?2026h[?25l ⠦ New essio    [?25h[?2026l[?2026h[?25l ⠧ Newsion   [?25h[?2026l[?2026h[?25l⠧New on  [?25h[?2026l[?2026h[?25l ⠧ Neon  [?25h[?2026l[?2026h[?25l ⠧ New ssn   [?25h[?2026l[?2026h[?25l ⠇ New ses     [?25h[?2026l[?2026h[?25l⠇ New ses   [?25h[?2026l[?2026h[?25l⠏ewsei   [?25h[?2026l[?2026h[?25l ⠏ Ne ssso   [?25h[?2026l[?2026h[?25l⠏ew sessi   [?25h[?2026l[?2026h[?25l⠋ Nw session   [?25h[?2026l[?2026h[?25lew s    [?25h[?2026l[?2026h[?25lNew session       [?25h[?2026l[?2026h[?25l⠙ ession         [?25h[?2026l[?2026h[?25l⠹session         [?25h[?2026l[?2026h[?25l ssion             [?25h[?2026l[?2026h[?25l ⠸n         [?25h[?2026l[?2026h[?25l          [?25h[?2026l[?2026h[?25l ⠸       [?25h[?2026l[?2026h[?25l ⠸          [?25h[?2026l[?2026h[?25l ⠼        [?25h[?2026l[?2026h[?25l ⠼      [?25h[?2026l[?2026h[?25l ⠼     [?25h[?2026l[?2026h[?25l ⠼         [?25h[?2026l[?2026h[?25l ⠴      [?25h[?2026l[?2026h[?25l⠴     [?25h[?2026l[?2026h[?25l⠴ Ne            [?25h[?2026l[?2026h[?25l⠦ New        [?25h[?2026l[?2026h[?25l ⠧New           [?25h[?2026l[?2026h[?25l ⠧ New session     ┃┃┃┃ ■⬝⬝⬝⬝⬝⬝⬝ esc interrupt [?25h[?2026l[?2026h[?25l ⠇ Nw s     ■■⬝⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l⠇ewses   [?25h[?2026l[?2026h[?25l⠇ New sei    ■■■⬝⬝⬝⬝⬝[?25h[?2026l[?2026h[?25l ⠇ Ne sss   [?25h[?2026l[?2026h[?25l⠏ New session    ■■■■⬝⬝⬝⬝[?25h[?2026l[?2026h[?25lNew session    ■■■■■⬝⬝⬝[?25h[?2026l[?2026h[?25lew session     [?25h[?2026l[?2026h[?25l⠋w session        ■■■■■■⬝⬝[?25h[?2026l[?2026h[?25lessn    ⬝■■■■■■⬝[?25h[?2026l[?2026h[?25l New session         Build · Fledge Alpha Free · 2.8s · 2.6 tok/s/…/uuuuu/orc...capture/work5.6K (1%)[?25h[?2026l]0;OpenCode[?2026h[?25l • New session o…/xxxxxxxx/[?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l • New session [?25h[?2026l[?2026h[?25l   [?25h[?2026l]0;OC | Single-word reply: ok[?2026h[?25lSingl-word reply: ok[?25h[?2026l[?2026h[?25lok[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-narrow-pane@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-narrow-pane@agent.json new file mode 100644 index 000000000000..efa766b469be --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-narrow-pane@agent.json @@ -0,0 +1,15 @@ +{ + "description": "opencode2 recording at 40x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-13: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "14-124: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "125-127: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-13: verdict=pending:closed wait=pending", + "14-124: verdict=ready-weak wait=ready@start", + "125-127: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-narrow-pane@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-narrow-pane@unknown.json new file mode 100644 index 000000000000..ea3223a4bf7f --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--opencode-2-0-21-timed-narrow-pane@unknown.json @@ -0,0 +1,15 @@ +{ + "description": "opencode2 recording at 40x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-13: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "14-124: now=ready-weak edge=ready-weak quiet=ready-weak wait=ready@start", + "125-127: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-13: verdict=pending:open wait=ready@poll", + "14-124: verdict=ready-weak wait=ready@start", + "125-127: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts index 8d7039daa5cf..1674e587da81 100644 --- a/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts +++ b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts @@ -4,6 +4,8 @@ * 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. + * OpenCode's agent-row signal reads screen structure no string walk expresses, so its reference is + * the scanner fed one char at a time; the transcript suites prove where that lands. */ import { readFileSync } from 'node:fs' @@ -17,17 +19,17 @@ import type { GrokStartupTraceChunk } from '../../shared/__fixtures__/grok-start import { readRuntimeFixture, readTimedRuntimeFixture } from './agent-transcript-replay-test-harness' type Walk = { anchor: string; end: string; marker: string } -type Walks = Walk | Walk[] const ALT_SCREEN: Walk = { anchor: '\x1b[?1049h', end: '\x1b[?1049l', marker: '' } -const BOX_CURSOR: Walk = { anchor: '\x1b[?2004h', end: '\x1b[?2004l', marker: '\x1b[?25h' } -// A signal with several walks is ready once all of them are. -const WALKS: Partial> = { - 'render-cursor-after-bracketed-paste': BOX_CURSOR, +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: '╭' }, - 'opencode-agent-row': [BOX_CURSOR, { ...ALT_SCREEN, marker: '\u00b7' }] + 'zcode-composer-prompt': { ...ALT_SCREEN, marker: '╭' } } // Recorded zsh shape: the prompt enables bracketed paste and accept-line disables it before exec. @@ -76,38 +78,29 @@ const CASES: Case[] = [ signal: 'zcode-composer-prompt', data: readRuntimeFixture('zcode-composer-ready') }, - { - name: 'opencode (untimed pty transcript), cursor signal mimo-code still uses', - signal: 'render-cursor-after-bracketed-paste', - data: readPtyTranscript('opencode.txt') - }, { name: 'opencode (untimed pty transcript)', - signal: 'opencode-agent-row', + signal: 'render-cursor-after-bracketed-paste', data: readPtyTranscript('opencode.txt') }, - ...OPENCODE_TIMED.flatMap((name): Case[] => { - const { chunks } = readTimedRuntimeFixture(name) - return [ - { name, signal: 'opencode-agent-row', data: chunks.join(''), reads: chunks }, - { - name: `${name} behind a zsh launch`, - signal: 'opencode-agent-row', - data: ZSH_LAUNCH_PROLOGUE + chunks.join(''), - reads: [ZSH_LAUNCH_PROLOGUE, ...chunks] - } - ] - }) + ...(['render-cursor-after-bracketed-paste', 'opencode-agent-row'] as const).flatMap((signal) => + OPENCODE_TIMED.flatMap((name): Case[] => { + const { chunks } = readTimedRuntimeFixture(name) + return [ + { name: `${name} (${signal})`, signal, data: chunks.join(''), reads: chunks }, + { + name: `${name} behind a zsh launch (${signal})`, + signal, + data: ZSH_LAUNCH_PROLOGUE + chunks.join(''), + reads: [ZSH_LAUNCH_PROLOGUE, ...chunks] + } + ] + }) + ) ] -/** End offset where every walk has seen its marker, or null when one never does. */ -function walkReadyEnd(data: string, walks: Walks): number | null { - const ends = (Array.isArray(walks) ? walks : [walks]).map((walk) => walkOne(data, walk)) - return ends.some((end) => end === null) ? null : Math.max(...ends.map((end) => end ?? 0)) -} - /** End offset of the first marker seen while the anchor is held, walking the whole string. */ -function walkOne(data: string, { anchor, end, marker }: Walk): number | null { +function walkReadyEnd(data: string, { anchor, end, marker }: Walk): number | null { let cursor = 0 for (;;) { const enter = data.indexOf(anchor, cursor) @@ -180,10 +173,16 @@ function chunkings(testCase: Case): Map { 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] + const expected = walk + ? walkReadyEnd(testCase.data, walk) + : (scanReadyRead( + testCase.signal, + splitAt(testCase.data, () => 1) + )?.[1] ?? null) if (!walk) { - throw new Error(`no reference walk for ${testCase.signal}`) + expect(testCase.signal).toBe('opencode-agent-row') + expect(expected, 'every OpenCode capture paints its agent row').not.toBeNull() } - const expected = walkReadyEnd(testCase.data, walk) for (const [label, reads] of chunkings(testCase)) { const readyRead = scanReadyRead(testCase.signal, reads) if (expected === null) { @@ -200,10 +199,18 @@ describe('draft-paste readiness does not depend on how the stream is chunked', ( }) it.each(OPENCODE_TIMED)('%s: a zsh launch in front moves nothing but the offset', (name) => { - const walk = WALKS['opencode-agent-row']! + 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!) + const agentRowEnd = (stream: string): number | undefined => + scanReadyRead( + 'opencode-agent-row', + splitAt(stream, () => 1) + )?.[1] + expect(agentRowEnd(ZSH_LAUNCH_PROLOGUE + data)).toBe( + ZSH_LAUNCH_PROLOGUE.length + agentRowEnd(data)! + ) }) }) diff --git a/src/main/runtime/launched-agent-composer-readiness.test.ts b/src/main/runtime/launched-agent-composer-readiness.test.ts index b0db510c493c..7f6c39e3efb0 100644 --- a/src/main/runtime/launched-agent-composer-readiness.test.ts +++ b/src/main/runtime/launched-agent-composer-readiness.test.ts @@ -1,7 +1,10 @@ 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 { + createDraftPasteReadyScanner, + resolvePasteReadySignal +} from '../../shared/draft-paste-ready-scanner' import type { TuiAgent } from '../../shared/tui-agent' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { @@ -70,6 +73,7 @@ const EXPECTED_LANES: Record = { const FIXTURES = join(__dirname, '__fixtures__') const OPENCODE_PLACEHOLDER = 'Ask anything' const AGENT_ROW = /\u00b7 \S/ +const BOX_BOTTOM_LEFT = '\u2579' const CITED_CAPTURES = Object.entries(TUI_AGENT_CONFIG).flatMap(([agent, row]) => (row.composerReadyCaptures ?? []).map((capture) => [agent, capture] as const) @@ -83,11 +87,11 @@ function readSignal(agent: string) { if (!isTuiAgent(agent)) { throw new Error(`${agent} is not a TuiAgent`) } - const signal = TUI_AGENT_CONFIG[agent].draftPasteReadySignal - if (!signal) { + if (!TUI_AGENT_CONFIG[agent].draftPasteReadySignal) { throw new Error(`${agent} cites composer-ready captures but has no draftPasteReadySignal`) } - return signal + // Worker start always presses Enter after its paste. + return resolvePasteReadySignal(TUI_AGENT_CONFIG[agent], true) } /** Index of the first read the scanner reports ready on, or -1. */ @@ -156,8 +160,10 @@ describe('every capture a row cites proves its input-box marker', () => { if (box !== -1 && boxRead === -1) { boxRead = read } - // The row under the box reads ` · `; a read can paint the separator first. - if (box !== -1 && frame.screenLines.slice(box + 1).some((line) => AGENT_ROW.test(line))) { + // The row inside the box's last line reads ` · `; a read can paint the + // separator and model before the agent. + const corner = frame.screenLines.findIndex((line) => line.includes(BOX_BOTTOM_LEFT)) + if (box !== -1 && corner > box && AGENT_ROW.test(frame.screenLines[corner - 1])) { rowRead = read break } diff --git a/src/main/runtime/opencode-agent-row-enter-transcript.test.ts b/src/main/runtime/opencode-agent-row-enter-transcript.test.ts index b580cbf67da2..8ead466a915d 100644 --- a/src/main/runtime/opencode-agent-row-enter-transcript.test.ts +++ b/src/main/runtime/opencode-agent-row-enter-transcript.test.ts @@ -2,7 +2,8 @@ * Proof that the agent-row signal is the moment OpenCode 2 can submit, from two natural cold * `--standalone` starts that replayed Orca's worker-start timing (paste at readiness, Enter 500 ms * plus paste time later, at promptSentAtMs): one pasted on the agent row, one on the box's cursor - * while a loaded machine kept the agent list from arriving. + * while a loaded machine kept the agent list from arriving. A third, in a pane too narrow for the + * row, pasted at the signal's grace instead. */ import { readFileSync } from 'node:fs' @@ -14,7 +15,7 @@ import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import { readTimedRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness' const HELD_PASTE = '[Pasted ~' -const AGENT_ROW_SIGNAL = TUI_AGENT_CONFIG.opencode2.draftPasteReadySignal! +const AGENT_ROW_SIGNAL = TUI_AGENT_CONFIG.opencode2.submitPasteReadySignal! // The box's show-cursor, the rule OpenCode used before the agent-row signal. const BOX_CURSOR_SIGNAL: DraftPasteReadySignal = 'render-cursor-after-bracketed-paste' @@ -60,4 +61,13 @@ describe('OpenCode 2 submits an Enter sent after the agent row and drops one sen expect(promptSentAtMs!).toBeLessThan(agentRowAt) expect(await finalScreen(name)).toContain(HELD_PASTE) }) + + it('submits the brief Orca pastes at the grace in a pane too narrow for the agent row', async () => { + const name = 'opencode-2-0-21-timed-narrow-pane' + const { promptSentAtMs } = readTimedRuntimeFixture(name) + expect(readyAtMs(name, BOX_CURSOR_SIGNAL)).toBeLessThan(promptSentAtMs!) + const screen = await finalScreen(name) + expect(screen).not.toContain(HELD_PASTE) + expect(screen).toContain('Task: reply with the single word') + }) }) diff --git a/src/main/runtime/opencode-agent-row-structure-transcript.test.ts b/src/main/runtime/opencode-agent-row-structure-transcript.test.ts new file mode 100644 index 000000000000..b5b25becd1ff --- /dev/null +++ b/src/main/runtime/opencode-agent-row-structure-transcript.test.ts @@ -0,0 +1,87 @@ +/** + * The agent-row signal keys on where OpenCode paints its separator, not on the character: a `·` in + * the footer path under the box or in a session tab title is drawn before the row in these real + * starts, and must not move the read the signal fires on. Each is written into a recorded capture + * in place of characters of the same length, so every read keeps its recorded size. + */ + +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { readTimedRuntimeFixture } from './agent-transcript-replay-test-harness' + +const OPENCODE_2_STARTS = [ + 'opencode-2-0-14-timed-cold-standalone', + 'opencode-2-0-18-timed-boot-hidden-pane', + 'opencode-2-0-21-timed-cold-standalone', + 'opencode-2-0-21-timed-cold-standalone-hidden-pane', + 'opencode-2-0-21-timed-natural-load-enter-dropped', + 'opencode-2-0-21-timed-enter-after-agent-row', + 'opencode-cmd-2-0-21-timed-warm-server' +] +// Only these starts paint a session tab strip before the row. +const TAB_STRIP_STARTS = [ + 'opencode-2-0-21-timed-natural-load-enter-dropped', + 'opencode-2-0-21-timed-enter-after-agent-row' +] +const ALT_SCREEN_ENTER = '\x1b[?1049h' +const SGR = '(?:\\x1b\\[[0-9;:]*m)*' +const FOOTER_DIRECTORY = 'col·lecció' + +function readyRead(chunks: readonly string[]): number { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + return chunks.findIndex((chunk) => scanner.observe(chunk).ready) +} + +/** Overwrites `data` at `offset` with `text`, then cuts it back into the recorded read sizes. */ +function overwrite(chunks: readonly string[], offset: number, text: string): string[] { + const data = chunks.join('') + const edited = data.slice(0, offset) + text + data.slice(offset + text.length) + let start = 0 + return chunks.map((chunk) => edited.slice(start, (start += chunk.length))) +} + +function readAt(chunks: readonly string[], offset: number): number { + let end = 0 + return chunks.findIndex((chunk) => (end += chunk.length) > offset) +} + +/** Offset of the footer path's first directory, on the line under the box's bottom corner. */ +function footerDirectoryOffset(data: string): number { + const alt = data.indexOf(ALT_SCREEN_ENTER) + const corner = new RegExp(`\\x1b\\[(\\d+);\\d+H${SGR}\\u2579`, 'g') + corner.lastIndex = alt + const cornerRow = Number(corner.exec(data)![1]) + const path = new RegExp(`\\x1b\\[${cornerRow + 1};\\d+H${SGR}(?:~|/[^/\\x1b]+)/`, 'g') + path.lastIndex = alt + const match = path.exec(data)! + return match.index + match[0].length +} + +/** Offset of the first character a session tab title paints on the top line. */ +function tabTitleCharOffset(data: string): number { + const title = new RegExp(`\\x1b\\[1;\\d+H${SGR}(?=[A-Za-z])`, 'g') + title.lastIndex = data.indexOf(ALT_SCREEN_ENTER) + const match = title.exec(data)! + return match.index + match[0].length +} + +describe('a separator OpenCode draws off the agent row does not make it ready', () => { + it.each(OPENCODE_2_STARTS)('%s: a footer path with a `·` in it', (name) => { + const { chunks } = readTimedRuntimeFixture(name) + const data = chunks.join('') + const at = footerDirectoryOffset(data) + // Only painted path text is replaced. + expect(data.slice(at, at + FOOTER_DIRECTORY.length)).toMatch(/^[\w./-]+$/) + const edited = overwrite(chunks, at, FOOTER_DIRECTORY) + expect(readAt(edited, at)).toBeLessThan(readyRead(chunks)) + expect(readyRead(edited)).toBe(readyRead(chunks)) + }) + + it.each(TAB_STRIP_STARTS)('%s: a session tab title with a `·` in it', (name) => { + const { chunks } = readTimedRuntimeFixture(name) + const at = tabTitleCharOffset(chunks.join('')) + const edited = overwrite(chunks, at, '·') + expect(readAt(edited, at)).toBeLessThan(readyRead(chunks)) + expect(readyRead(edited)).toBe(readyRead(chunks)) + }) +}) diff --git a/src/main/runtime/opencode-worker-composer-transcript.test.ts b/src/main/runtime/opencode-worker-composer-transcript.test.ts index f34794dfdc0f..e49254defddb 100644 --- a/src/main/runtime/opencode-worker-composer-transcript.test.ts +++ b/src/main/runtime/opencode-worker-composer-transcript.test.ts @@ -4,10 +4,14 @@ * so a wait that trusts a bare-name title has its answer before OpenCode draws anything. */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../shared/opencode-agent-row-scanner' 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 { readTimedRuntimeFixture, replayTranscript } from './agent-transcript-replay-test-harness' import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' vi.mock('electron', () => ({ @@ -36,7 +40,8 @@ const RUNS: [string, TuiAgent][] = [ ['opencode-2-0-14-timed-cold-standalone', 'opencode2'] ] const OPENCODE_1_RUNS = RUNS.filter(([name]) => name.startsWith('opencode-1-')) -const AGENT_ROW_SEPARATOR = '\u00b7' +const AGENT_ROW = /\u00b7 \S/ +const BOX_BOTTOM_LEFT = '\u2579' /** The read that ends the synchronized update drawing OpenCode's input box. */ function boxRead(chunks: string[]): number { @@ -48,12 +53,20 @@ function boxRead(chunks: string[]): number { return chunks.findIndex((chunk) => (end += chunk.length) >= boxEnd) } -/** The read that paints the separator in the agent/model row under the box. */ -function agentRowRead(chunks: string[]): number { - const data = chunks.join('') - const separator = data.indexOf(AGENT_ROW_SEPARATOR, data.indexOf('\x1b[?1049h')) - let end = 0 - return chunks.findIndex((chunk) => (end += chunk.length) > separator) +/** The first read whose screen shows `· ` on the box's last line, above its corner. */ +async function agentRowRead(name: string, chunks: string[]): Promise { + const meta: { cols: number; rows: number } = JSON.parse( + readFileSync(join(__dirname, '__fixtures__', `${name}.meta.json`), 'utf8') + ) + let read = 0 + for await (const { screenLines } of replayTranscript(chunks, meta.cols, meta.rows)) { + const corner = screenLines.findIndex((line) => line.includes(BOX_BOTTOM_LEFT)) + if (corner > 0 && AGENT_ROW.test(screenLines[corner - 1])) { + return read + } + read += 1 + } + return -1 } async function replay(name: string, agent: TuiAgent) { @@ -67,13 +80,15 @@ async function replay(name: string, agent: TuiAgent) { }) vi.useFakeTimers() runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ZSH_LAUNCH, Date.now()) - const settledAt: { composer: number | null; tuiIdle: number | null } = { - composer: null, - tuiIdle: null - } + const settledAt: { composer: number | null; composerMs: number | null; tuiIdle: number | null } = + { composer: null, composerMs: null, tuiIdle: null } let read = -1 + const startedAt = Date.now() const composer = waitForLaunchedAgentComposer(runtime, handle, agent, 60_000) - void composer.then(() => (settledAt.composer ??= read)) + void composer.then(() => { + settledAt.composer ??= read + settledAt.composerMs ??= Date.now() - startedAt + }) const tuiIdle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) void tuiIdle.then( () => (settledAt.tuiIdle ??= read), @@ -88,7 +103,12 @@ async function replay(name: string, agent: TuiAgent) { runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) await vi.advanceTimersByTimeAsync(0) } - return { settledAt, composer, boxRead: boxRead(chunks), agentRowRead: agentRowRead(chunks) } + return { + settledAt, + composer, + boxRead: boxRead(chunks), + agentRowRead: await agentRowRead(name, chunks) + } } describe('an OpenCode worker gets its task only once OpenCode can submit it', () => { @@ -122,6 +142,22 @@ describe('an OpenCode worker gets its task only once OpenCode can submit it', () expect(settledAt.composer).toBeGreaterThan(boxRead) }) + it('takes the box after the grace in a pane too narrow for the agent row', async () => { + const name = 'opencode-2-0-21-timed-narrow-pane' + const { chunks, times, promptSentAtMs } = readTimedRuntimeFixture(name) + const cursor = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') + const boxCursorAt = times[chunks.findIndex((chunk) => cursor.observe(chunk).ready)] + const { settledAt, composer, agentRowRead } = await replay(name, 'opencode2') + expect(agentRowRead).toBe(-1) + await expect(composer).resolves.toMatchObject({ satisfied: true }) + // Fake timers drop the recorded times' fraction of a millisecond. + expect( + Math.abs(settledAt.composerMs! - (boxCursorAt + OPENCODE_AGENT_ROW_GRACE_MS)) + ).toBeLessThan(1) + // The recorder pasted at the grace and pressed Enter after it, and OpenCode took it. + expect(promptSentAtMs!).toBeGreaterThan(settledAt.composerMs!) + }) + it.each(RUNS)( '%s: the bare-name tui-idle wait main used would have settled before the box', async (name, agent) => { diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 0b50aa9a5d77..b4916a6ccf88 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -190,7 +190,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM { ...this.getWorktreeStartupReadinessHost(), getPtyId: () => initialPtyId }, handle, agent, - { timeoutMs, requireComposerMarker: true } + { timeoutMs, requireComposerMarker: true, submit: true } ) if (!ptyId) { throw new Error('timeout') diff --git a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts index d83bcfb8511b..5a5c95214031 100644 --- a/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts +++ b/src/main/runtime/orca-runtime-tests/worktree-setup-and-startup-part-03.spec.ts @@ -554,12 +554,7 @@ describe('OrcaRuntimeService', () => { // Past the old 8s default, where readiness would previously have been abandoned. await vi.advanceTimersByTimeAsync(10_000) - // The box and its cursor, then the agent/model row once OpenCode's agent list has loaded. - runtime.onPtyData( - 'pty-opencode-draft-budget', - '\x1b[?1049h\x1b[?2004h\x1b[?25hBuild \u00b7 Some Model', - Date.now() - ) + runtime.onPtyData('pty-opencode-draft-budget', '\x1b[?2004h\x1b[?25h', Date.now()) await Promise.resolve() await Promise.resolve() diff --git a/src/main/runtime/runtime-worktree-startup-readiness.test.ts b/src/main/runtime/runtime-worktree-startup-readiness.test.ts index e96a072f7ed2..a6d6c5df431c 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.test.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../shared/opencode-agent-row-scanner' import { waitForWorktreeStartupDraft, type WorktreeStartupReadinessHost @@ -69,3 +70,97 @@ describe('fresh worker composer readiness', () => { expect(h.unsubscribe).toHaveBeenCalledOnce() }) }) + +describe('OpenCode submit readiness', () => { + afterEach(() => vi.useRealTimers()) + + // OpenCode 2.0.21's box with its cursor and bottom corner, then the agent row directly above it. + const BOX = '\x1b[?1049h\x1b[?2004h\x1b[24;24H┃\x1b[25;24H╹\x1b[22;27H\x1b[?25h' + const AGENT_ROW = '\x1b[24;27HBuild\x1b[24;33H\u00b7\x1b[24;35HSome Model' + + function fixture() { + let listener = (_data: string): void => {} + const host: WorktreeStartupReadinessHost = { + getPtyId: () => 'pty-1', + getForegroundProcess: async () => 'opencode', + subscribeToData: (_ptyId, onData) => { + listener = onData + return () => {} + }, + readRecentOutput: () => undefined, + write: vi.fn() + } + return { host, emit: (data: string) => listener(data) } + } + + function settle(pending: Promise): { value: string | null | undefined } { + const result: { value: string | null | undefined } = { value: undefined } + void pending.then((value) => (result.value = value)) + return result + } + + it('a draft that is not submitted is pasted on the box cursor, as before', async () => { + vi.useFakeTimers() + const h = fixture() + const result = settle(waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2')) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(0) + expect(result.value).toBe('pty-1') + }) + + it('a submitted task waits for the agent row', async () => { + vi.useFakeTimers() + const h = fixture() + const result = settle( + waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', { + timeoutMs: 60_000, + requireComposerMarker: true, + submit: true + }) + ) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(1000) + expect(result.value).toBeUndefined() + h.emit(AGENT_ROW) + await vi.advanceTimersByTimeAsync(0) + expect(result.value).toBe('pty-1') + expect(vi.getTimerCount()).toBe(0) + }) + + it('takes the box after the grace when the row never comes, whatever repaints', async () => { + vi.useFakeTimers() + const h = fixture() + const result = settle( + waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', { + timeoutMs: 60_000, + requireComposerMarker: true, + submit: true + }) + ) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS - 1) + h.emit('\x1b[22;27H\x1b[?25h') + expect(result.value).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + expect(result.value).toBe('pty-1') + expect(vi.getTimerCount()).toBe(0) + }) + + it('withdraws the grace when OpenCode turns bracketed paste off', async () => { + vi.useFakeTimers() + const h = fixture() + const result = settle( + waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', { + timeoutMs: 60_000, + requireComposerMarker: true, + submit: true + }) + ) + h.emit(BOX) + h.emit('\x1b[?2004l\x1b[?1049l') + await vi.advanceTimersByTimeAsync(59_999) + expect(result.value).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + expect(result.value).toBeNull() + }) +}) diff --git a/src/main/runtime/runtime-worktree-startup-readiness.ts b/src/main/runtime/runtime-worktree-startup-readiness.ts index 779f16ec6a40..c0ca91fdeb68 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.ts @@ -1,6 +1,9 @@ import { isShellProcess } from '../../shared/agent-detection' import { isExpectedAgentProcess } from '../../shared/agent-process-recognition' -import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import { + createDraftPasteReadyScanner, + resolvePasteReadySignal +} from '../../shared/draft-paste-ready-scanner' import { resolveDraftPasteReadyTimeoutMs } from '../../shared/draft-paste-ready-timeout' import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' import type { TuiAgent } from '../../shared/tui-agent' @@ -91,18 +94,18 @@ export function waitForWorktreeStartupDraft( host: WorktreeStartupReadinessHost, handle: string, agent: TuiAgent, - options: { timeoutMs?: number; requireComposerMarker?: boolean } = {} + options: { timeoutMs?: number; requireComposerMarker?: boolean; submit?: boolean } = {} ): Promise { const ptyId = host.getPtyId(handle) if (!ptyId) { return Promise.resolve(null) } - const signal = - TUI_AGENT_CONFIG[agent].draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' + const signal = resolvePasteReadySignal(TUI_AGENT_CONFIG[agent], options.submit === true) return new Promise((resolve) => { let settled = false const scanner = createDraftPasteReadyScanner(signal) let quietTimer: NodeJS.Timeout | null = null + let graceTimer: NodeJS.Timeout | null = null let hardTimer: NodeJS.Timeout | null = null let unsubscribe: (() => void) | null = null const finish = (value: string | null): void => { @@ -113,6 +116,9 @@ export function waitForWorktreeStartupDraft( if (quietTimer) { clearTimeout(quietTimer) } + if (graceTimer) { + clearTimeout(graceTimer) + } if (hardTimer) { clearTimeout(hardTimer) } @@ -127,6 +133,12 @@ export function waitForWorktreeStartupDraft( if (result.ready) { return finish(ptyId) } + if (result.readyAfterMs === null && graceTimer) { + clearTimeout(graceTimer) + graceTimer = null + } else if (typeof result.readyAfterMs === 'number' && !graceTimer) { + graceTimer = setTimeout(() => finish(ptyId), result.readyAfterMs) + } if (result.armQuietTimer && !options.requireComposerMarker) { if (quietTimer) { clearTimeout(quietTimer) diff --git a/src/renderer/src/lib/agent-draft-readiness.test.ts b/src/renderer/src/lib/agent-draft-readiness.test.ts index fe0ce62c4a9c..ae4ff400afaf 100644 --- a/src/renderer/src/lib/agent-draft-readiness.test.ts +++ b/src/renderer/src/lib/agent-draft-readiness.test.ts @@ -4,6 +4,7 @@ import { clearPreHandlerPtyState, drainPreHandlerPtyData } from '@/components/terminal-pane/pty-pre-handler-buffer' +import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../../shared/opencode-agent-row-scanner' import { waitForAgentDraftInputReady } from './agent-draft-readiness' const testState = vi.hoisted(() => ({ @@ -25,12 +26,15 @@ vi.mock('@/runtime/runtime-terminal-inspection', () => ({ const PTY_ID = 'pty-buffered-codex' const CODEX_COMPOSER = '\x1b[?1049h\x1b[1m›\x1b[0m Implement {feature}' const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +// OpenCode's box with its cursor and bottom corner, before the agent row under it (2.0.21 shape). +const OPENCODE_BOX = '\x1b[?1049h\x1b[?2004h\x1b[24;24H┃\x1b[25;24H╹\x1b[22;27H\x1b[?25h' describe('waitForAgentDraftInputReady', () => { afterEach(() => { clearPreHandlerPtyState(PTY_ID) testState.observer = null testState.unsubscribe.mockReset() + vi.unstubAllGlobals() vi.useRealTimers() }) @@ -52,4 +56,36 @@ describe('waitForAgentDraftInputReady', () => { ]) expect(vi.getTimerCount()).toBe(0) }) + + it('falls back to the box after the grace when OpenCode never paints its agent row', async () => { + vi.useFakeTimers() + vi.stubGlobal('window', globalThis) + let settled: boolean | null = null + void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row', {}).then( + (ready) => (settled = ready) + ) + testState.observer!(OPENCODE_BOX) + await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS - 1) + // More box frames do not restart the grace. + testState.observer!('\x1b[22;27H\x1b[?25h') + expect(settled).toBeNull() + await vi.advanceTimersByTimeAsync(1) + expect(settled).toBe(true) + expect(vi.getTimerCount()).toBe(0) + }) + + it('withdraws the grace when OpenCode turns bracketed paste off', async () => { + vi.useFakeTimers() + vi.stubGlobal('window', globalThis) + let settled: boolean | null = null + void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row', {}).then( + (ready) => (settled = ready) + ) + testState.observer!(OPENCODE_BOX) + testState.observer!('\x1b[?2004l') + await vi.advanceTimersByTimeAsync(19_999) + expect(settled).toBeNull() + await vi.advanceTimersByTimeAsync(1) + expect(settled).toBe(false) + }) }) diff --git a/src/renderer/src/lib/agent-draft-readiness.ts b/src/renderer/src/lib/agent-draft-readiness.ts index 25be94193da1..867fde216e87 100644 --- a/src/renderer/src/lib/agent-draft-readiness.ts +++ b/src/renderer/src/lib/agent-draft-readiness.ts @@ -28,6 +28,7 @@ export function waitForAgentDraftInputReady( let settled = false const scanner = createDraftPasteReadyScanner(readySignal) let quietTimer: number | null = null + let graceTimer: number | null = null let hardTimer: number | null = null let unsubscribe: (() => void) | null = null @@ -42,6 +43,9 @@ export function waitForAgentDraftInputReady( if (quietTimer !== null) { window.clearTimeout(quietTimer) } + if (graceTimer !== null) { + window.clearTimeout(graceTimer) + } unsubscribe?.() resolve(value) } @@ -54,11 +58,17 @@ export function waitForAgentDraftInputReady( } const observeData = (data: string): void => { - const { ready, armQuietTimer: shouldArm } = scanner.observe(data) + const { ready, armQuietTimer: shouldArm, readyAfterMs } = scanner.observe(data) if (ready) { finish(true) return } + if (readyAfterMs === null && graceTimer !== null) { + window.clearTimeout(graceTimer) + graceTimer = null + } else if (typeof readyAfterMs === 'number' && graceTimer === null) { + graceTimer = window.setTimeout(() => finish(true), readyAfterMs) + } if (shouldArm) { armQuietTimer() } 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 1b40f8da5e0f..f9b4d6fbb66a 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 @@ -85,17 +85,41 @@ describe('pty-bound agent draft readiness budget', () => { await vi.advanceTimersByTimeAsync(10_000) await expect(promise).resolves.toBe(true) - expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, 'opencode-agent-row', {}) + expect(testState.waitForReady).toHaveBeenCalledWith( + 'pty-1', + 20_000, + 'render-cursor-after-bracketed-paste', + {} + ) expect(testState.sendInput).toHaveBeenCalledTimes(1) expect(onUnconfirmedDelivery).not.toHaveBeenCalled() }) - it('flags a blind paste when only the agent process, not its composer, was seen', async () => { + it.each(['opencode', 'opencode2'] as const)( + 'waits for the %s agent row only when Enter follows the paste', + async (agent) => { + const promise = pasteDraftToAgentPtyWhenReady({ + tabId: 'tab-1', + ptyId: 'pty-1', + content: 'task', + agent, + submit: true, + forcePaste: true + }) + + await vi.advanceTimersByTimeAsync(12_000) + + await expect(promise).resolves.toBe(true) + expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, 'opencode-agent-row', {}) + } + ) + + 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. testState.waitForReady.mockResolvedValue(false) testState.inspectProcess.mockResolvedValue({ - foregroundProcess: 'grok', + foregroundProcess: 'opencode', hasChildProcesses: false }) const onUnconfirmedDelivery = vi.fn() @@ -103,7 +127,7 @@ describe('pty-bound agent draft readiness budget', () => { tabId: 'tab-1', ptyId: 'pty-1', content: 'draft', - agent: 'grok', + agent: 'opencode', forcePaste: true, onUnconfirmedDelivery }) @@ -115,32 +139,6 @@ describe('pty-bound agent draft readiness budget', () => { expect(onUnconfirmedDelivery).toHaveBeenCalledTimes(1) }) - it('reports a timeout instead of pasting blind when OpenCode never paints its agent row', async () => { - testState.waitForReady.mockResolvedValue(false) - testState.inspectProcess.mockResolvedValue({ - foregroundProcess: 'opencode', - hasChildProcesses: false - }) - const onTimeout = vi.fn() - const onUnconfirmedDelivery = vi.fn() - const promise = pasteDraftToAgentPtyWhenReady({ - tabId: 'tab-1', - ptyId: 'pty-1', - content: 'draft', - agent: 'opencode', - forcePaste: true, - onTimeout, - onUnconfirmedDelivery - }) - - await vi.advanceTimersByTimeAsync(1000) - - await expect(promise).resolves.toBe(false) - expect(onTimeout).toHaveBeenCalledTimes(1) - expect(onUnconfirmedDelivery).not.toHaveBeenCalled() - expect(testState.sendInput).not.toHaveBeenCalled() - }) - it('keeps the 8s readiness deadline for agents without an agent-specific budget', async () => { const onTimeout = vi.fn() const promise = pasteDraftToAgentPtyWhenReady({ diff --git a/src/renderer/src/lib/agent-paste-draft.test.ts b/src/renderer/src/lib/agent-paste-draft.test.ts index 23ae87eb3651..ca5f39304175 100644 --- a/src/renderer/src/lib/agent-paste-draft.test.ts +++ b/src/renderer/src/lib/agent-paste-draft.test.ts @@ -69,9 +69,6 @@ vi.mock('@/runtime/runtime-terminal-stream', () => ({ const DECSET_BRACKETED_PASTE = '\x1b[?2004h' const SHOW_CURSOR = '\x1b[?25h' -// OpenCode's box and cursor, then the agent/model row it paints once its agent list loads. -const OPENCODE_BOX = `\x1b[?1049h${DECSET_BRACKETED_PASTE}${SHOW_CURSOR}` -const OPENCODE_AGENT_ROW = 'Build \u00b7 Some Model' const CODEX_COMPOSER_PROMPT_RENDER = '\x1b[1m›\x1b[0m Ask Codex to do anything' const CODEX_DYNAMIC_COMPOSER_PROMPT_RENDER = '\x1b[?1049h\x1b[1m›\x1b[0m Implement {feature}' const ISSUE_URL = 'https://github.com/stablyai/orca/issues/123' @@ -208,7 +205,7 @@ describe('pasteDraftWhenAgentReady', () => { ) }) - it('pastes into opencode once the agent row renders under its box, not on the box alone', async () => { + it('pastes into opencode as soon as show-cursor renders after bracketed paste is enabled', async () => { const promise = pasteDraftWhenAgentReady({ tabId: 'tab-1', content: ISSUE_URL, @@ -216,11 +213,11 @@ describe('pasteDraftWhenAgentReady', () => { }) await flushMicrotasks() - testState.ptyObserver?.(OPENCODE_BOX) + testState.ptyObserver?.(DECSET_BRACKETED_PASTE) await flushMicrotasks() expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() - testState.ptyObserver?.(OPENCODE_AGENT_ROW) + testState.ptyObserver?.(SHOW_CURSOR) await expect(promise).resolves.toBe(true) expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( @@ -239,7 +236,7 @@ describe('pasteDraftWhenAgentReady', () => { }) await flushMicrotasks() - testState.ptyObserver?.(OPENCODE_BOX) + testState.ptyObserver?.(DECSET_BRACKETED_PASTE) await flushMicrotasks() for (let index = 0; index < 5; index += 1) { await vi.advanceTimersByTimeAsync(1499) @@ -248,7 +245,7 @@ describe('pasteDraftWhenAgentReady', () => { expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() } - testState.ptyObserver?.(OPENCODE_AGENT_ROW) + testState.ptyObserver?.(SHOW_CURSOR) await expect(promise).resolves.toBe(true) expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( @@ -262,7 +259,7 @@ describe('pasteDraftWhenAgentReady', () => { it('does not paste on the quiet window for opencode (it never arms one)', async () => { // Why: opencode is silent for ~1.5-2s between enabling bracketed paste and // mounting its composer. A quiet window would fire during that gap and paste - // before the composer exists (the original bug), so its readiness signal must + // before the composer exists (the original bug), so the cursor signal must // not arm one. With process inspection failing, delivery times out instead. testState.inspectRuntimeTerminalProcess.mockResolvedValue(null) const onTimeout = vi.fn() @@ -291,32 +288,33 @@ describe('pasteDraftWhenAgentReady', () => { expect(onTimeout).toHaveBeenCalledTimes(1) }) - it('reports a timeout instead of pasting blind when OpenCode never paints its agent row', async () => { - // Why: OpenCode drops an Enter until the row exists, so a blind paste would sit unsent in its - // box. The running process does not change that; the caller gets its timeout notice. + it('best-effort pastes for opencode at the hard timeout when its process is running', async () => { + // Why: with no quiet window, the hard-timeout process-ownership check is the + // backstop if show-cursor is somehow missed — same model as Codex. testState.inspectRuntimeTerminalProcess.mockResolvedValue({ foregroundProcess: 'opencode', hasChildProcesses: false }) - const onTimeout = vi.fn() const onUnconfirmedDelivery = vi.fn() const promise = pasteDraftWhenAgentReady({ tabId: 'tab-1', content: ISSUE_URL, agent: 'opencode', - onTimeout, onUnconfirmedDelivery }) await flushMicrotasks() - testState.ptyObserver?.(OPENCODE_BOX) + testState.ptyObserver?.(DECSET_BRACKETED_PASTE) await vi.advanceTimersByTimeAsync(20_000) - await flushMicrotasks(5) - await expect(promise).resolves.toBe(false) - expect(testState.sendRuntimePtyInputVerified).not.toHaveBeenCalled() - expect(onTimeout).toHaveBeenCalledTimes(1) - expect(onUnconfirmedDelivery).not.toHaveBeenCalled() + await expect(promise).resolves.toBe(true) + expect(testState.sendRuntimePtyInputVerified).toHaveBeenCalledWith( + {}, + 'pty-1', + PASTED_ISSUE_URL + ) + // The composer was never observed; the caller must be able to hedge its success notice. + expect(onUnconfirmedDelivery).toHaveBeenCalledTimes(1) }) it('keeps the existing fallback budget for unrelated markerless agents', async () => { diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index 69c619688fee..4c947eebdd45 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -1,8 +1,9 @@ import type { GlobalSettings } from '../../../shared/global-settings-types' import type { TuiAgent } from '../../../shared/tui-agent' import type { TerminalInputKind } from '../../../shared/terminal-input-kind' -import { TUI_AGENT_CONFIG, type DraftPasteReadySignal } from '../../../shared/tui-agent-config' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' import { resolveDraftPasteReadyTimeoutMs } from '../../../shared/draft-paste-ready-timeout' +import { resolvePasteReadySignal } from '../../../shared/draft-paste-ready-scanner' import { useAppStore } from '@/store' import { inspectRuntimeTerminalProcess, @@ -43,10 +44,6 @@ export const POST_PASTE_SUBMIT_DELAY_MS = 50 // also stops one slow step from spending the other's budget (STA-3367). const PTY_SPAWN_TIMEOUT_MS = 8000 -// Why: OpenCode drops an Enter until its agent row exists, so a blind paste would only leave the -// draft sitting unsent in its input box; these signals report the timeout instead. -const NO_BLIND_PASTE_SIGNALS: ReadonlySet = new Set(['opencode-agent-row']) - export function getSettingsForAgentTabRuntimeOwner( tabId: string ): Pick | null | undefined { @@ -102,7 +99,7 @@ export async function pasteDraftWhenAgentReady(args: { return false } - const readySignal = agentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' + const readySignal = resolvePasteReadySignal(agentConfig, submit === true) const settings = getSettingsForAgentTabRuntimeOwner(tabId) const readinessTimeoutMs = resolveDraftPasteReadyTimeoutMs(agent, timeoutMs) const readiness = await waitForAgentDraftInputReadyOnTab({ @@ -123,17 +120,16 @@ export async function pasteDraftWhenAgentReady(args: { // this sidecar subscription attaches. If process/title inspection says the // launched agent owns the PTY, fall back to a best-effort paste instead of // silently dropping generated prompts. - const fallbackReady = - agentConfig && !NO_BLIND_PASTE_SIGNALS.has(readySignal) - ? await waitForAgentReady(tabId, agentConfig.expectedProcess, { timeoutMs: 1000 }) - : { ready: false } + const fallbackReady = agentConfig + ? await waitForAgentReady(tabId, agentConfig.expectedProcess, { timeoutMs: 1000 }) + : { ready: false } if (!fallbackReady.ready) { 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 was never observed (e.g. the readiness + // budget expired mid-startup, #22479). Callers must be able to tell this blind write apart + // from a real delivery. onUnconfirmedDelivery?.() } @@ -177,14 +173,13 @@ export async function pasteDraftToAgentPtyWhenReady(args: { } const settings = getSettingsForAgentTabRuntimeOwner(tabId) - const readySignal = agentConfig?.draftPasteReadySignal ?? 'render-quiet-after-bracketed-paste' + const readySignal = resolvePasteReadySignal(agentConfig, submit === true) const budget = resolveDraftPasteReadyTimeoutMs(agent, timeoutMs) const ready = await waitForAgentDraftInputReady(ptyId, budget, readySignal, settings) if (!ready) { - const fallbackReady = - agentConfig && !NO_BLIND_PASTE_SIGNALS.has(readySignal) - ? await waitForExpectedAgentOnPty(ptyId, agentConfig.expectedProcess, 1000, settings) - : false + const fallbackReady = agentConfig + ? await waitForExpectedAgentOnPty(ptyId, agentConfig.expectedProcess, 1000, settings) + : false if (!fallbackReady) { onTimeout?.() return false diff --git a/src/shared/draft-paste-ready-scanner.test.ts b/src/shared/draft-paste-ready-scanner.test.ts index e68ffb51fd2c..fba771abddeb 100644 --- a/src/shared/draft-paste-ready-scanner.test.ts +++ b/src/shared/draft-paste-ready-scanner.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' -import { createDraftPasteReadyScanner } from './draft-paste-ready-scanner' +import { createDraftPasteReadyScanner, resolvePasteReadySignal } from './draft-paste-ready-scanner' +import { OPENCODE_AGENT_ROW_GRACE_MS } from './opencode-agent-row-scanner' +import { TUI_AGENT_CONFIG } from './tui-agent-config' const DECSET_BRACKETED_PASTE = '\x1b[?2004h' const DECRST_BRACKETED_PASTE = '\x1b[?2004l' @@ -14,7 +16,7 @@ const GROK_ALT_SCREEN_LEAVE = '\x1b[?1049l\x1b[?25h' const GROK_COMPOSER_FRAME = '\x1b[38;2;80;80;88m│\x1b[38;2;200;200;200m❯ \x1b[0m' describe('createDraftPasteReadyScanner', () => { - describe('render-cursor-after-bracketed-paste (mimo-code)', () => { + describe('render-cursor-after-bracketed-paste (opencode / mimo-code)', () => { it('is ready when show-cursor renders after bracketed paste in one chunk', () => { const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${SHOW_CURSOR}`)).toEqual({ @@ -121,19 +123,49 @@ describe('createDraftPasteReadyScanner', () => { }) describe('opencode-agent-row', () => { - // OpenCode's box, then the row under it once its agent list has loaded (shape from 2.0.21). - const BOX = `${ALT_SCREEN_ENTER}${DECSET_BRACKETED_PASTE}┃ Ask anything…${SHOW_CURSOR}` - const AGENT_ROW = '\x1b[29;47HBuild\x1b[0m\x1b[29;53H\u00b7\x1b[0m\x1b[29;55HSome Model' + // Frame shapes from the OpenCode 2.0.21 cold-start capture: the box with its bottom-left + // corner `╹` on row 25, then a later frame painting ` · ` on row 24 above it. + const frame = (body: string): string => + `\x1b[?2026h${HIDE_CURSOR}${body}\x1b[22;27H${SHOW_CURSOR}\x1b[?2026l` + const BOX = `${ALT_SCREEN_ENTER}${DECSET_BRACKETED_PASTE}${frame( + '\x1b[21;24H┃\x1b[22;24H┃\x1b[23;24H┃\x1b[24;24H┃\x1b[25;24H╹' + )}` + const AGENT_ROW = frame('\x1b[24;27HBuild\x1b[24;33H\u00b7\x1b[24;35HSome Model') + const GRACE = { ready: false, armQuietTimer: false, readyAfterMs: OPENCODE_AGENT_ROW_GRACE_MS } + + it('is not ready on the input box and its cursor alone, and asks for the grace timer', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + expect(scanner.observe(BOX)).toEqual(GRACE) + }) - it('is not ready on the input box and its cursor alone', () => { + it('is ready once the row directly above the box corner paints its separator', () => { const scanner = createDraftPasteReadyScanner('opencode-agent-row') - expect(scanner.observe(BOX)).toEqual({ ready: false, armQuietTimer: false }) + scanner.observe(BOX) + expect(scanner.observe(AGENT_ROW)).toEqual({ + ready: true, + armQuietTimer: false, + readyAfterMs: null + }) }) - it('is ready once the agent row paints its separator', () => { + it('is ready when OpenCode 1 paints the row before the corner in the same frame', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + const opencode1Box = frame('\x1b[23;27HBuild\x1b[23;33H\u00b7\x1b[24;24H╹') + expect( + scanner.observe(`${ALT_SCREEN_ENTER}${DECSET_BRACKETED_PASTE}${opencode1Box}`).ready + ).toBe(true) + }) + + it('ignores a separator in the footer path under the box', () => { const scanner = createDraftPasteReadyScanner('opencode-agent-row') scanner.observe(BOX) - expect(scanner.observe(AGENT_ROW)).toEqual({ ready: true, armQuietTimer: false }) + expect(scanner.observe(frame('\x1b[26;24H~/col\u00b7lecció/work'))).toEqual(GRACE) + }) + + it('ignores a separator in a session tab title', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + scanner.observe(BOX) + expect(scanner.observe(frame('\x1b[1;4Hx\x1b[1;5H\u00b7\x1b[1;6Hy'))).toEqual(GRACE) }) it('ignores a separator a shell prompt draws before OpenCode enters the alternate screen', () => { @@ -142,14 +174,50 @@ describe('createDraftPasteReadyScanner', () => { scanner.observe( `${DECSET_BRACKETED_PASTE}~ \u00b7 main % opencode${DECRST_BRACKETED_PASTE}` ) - ).toEqual({ ready: false, armQuietTimer: false }) + ).toEqual({ ready: false, armQuietTimer: false, readyAfterMs: null }) expect(scanner.observe(BOX).ready).toBe(false) }) - it('ignores a separator painted after OpenCode leaves the alternate screen', () => { + it('does not keep a row from an earlier alternate-screen session', () => { const scanner = createDraftPasteReadyScanner('opencode-agent-row') - scanner.observe(BOX) - expect(scanner.observe(`${ALT_SCREEN_LEAVE}~ \u00b7 main %`).ready).toBe(false) + expect(scanner.observe(BOX + AGENT_ROW).ready).toBe(true) + expect(scanner.observe(ALT_SCREEN_LEAVE).ready).toBe(false) + expect(scanner.observe(BOX)).toEqual(GRACE) + }) + + it('does not latch a part an earlier session established and then revoked', () => { + // The review probe's shapes: another alternate-screen app drew a separator and left; a shell + // showed its cursor under bracketed paste and turned it off. + const leftApp = createDraftPasteReadyScanner('opencode-agent-row') + leftApp.observe(`${ALT_SCREEN_ENTER}x \u00b7 y${ALT_SCREEN_LEAVE}`) + expect(leftApp.observe(BOX)).toEqual(GRACE) + const shell = createDraftPasteReadyScanner('opencode-agent-row') + shell.observe(`${DECSET_BRACKETED_PASTE}% ${SHOW_CURSOR}${DECRST_BRACKETED_PASTE}`) + expect( + shell.observe(`${ALT_SCREEN_ENTER}${DECSET_BRACKETED_PASTE}footer \u00b7 path`).ready + ).toBe(false) + }) + + it('withdraws readiness and the grace timer when bracketed paste is turned off', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + expect(scanner.observe(BOX + AGENT_ROW).ready).toBe(true) + expect(scanner.observe(DECRST_BRACKETED_PASTE)).toEqual({ + ready: false, + armQuietTimer: false, + readyAfterMs: null + }) + const graced = createDraftPasteReadyScanner('opencode-agent-row') + expect(graced.observe(BOX).readyAfterMs).toBe(OPENCODE_AGENT_ROW_GRACE_MS) + expect(graced.observe(DECRST_BRACKETED_PASTE).readyAfterMs).toBeNull() + }) + + it('needs the box cursor shown while bracketed paste is held, not one a shell showed', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + scanner.observe(`${DECSET_BRACKETED_PASTE}% ${SHOW_CURSOR}${DECRST_BRACKETED_PASTE}`) + const rowWithoutCursor = '\x1b[25;24H╹\x1b[24;33H\u00b7' + expect( + scanner.observe(`${ALT_SCREEN_ENTER}${DECSET_BRACKETED_PASTE}${rowWithoutCursor}`).ready + ).toBe(false) }) it('never arms the quiet window', () => { @@ -383,3 +451,20 @@ describe('createDraftPasteReadyScanner', () => { }) }) }) + +describe('resolvePasteReadySignal', () => { + it.each(['opencode', 'opencode2'] as const)( + '%s: only a paste that Enter follows waits for the agent row', + (agent) => { + expect(resolvePasteReadySignal(TUI_AGENT_CONFIG[agent], false)).toBe( + 'render-cursor-after-bracketed-paste' + ) + expect(resolvePasteReadySignal(TUI_AGENT_CONFIG[agent], true)).toBe('opencode-agent-row') + } + ) + + it('falls back to the draft signal, then the quiet window', () => { + expect(resolvePasteReadySignal(TUI_AGENT_CONFIG.codex, true)).toBe('codex-composer-prompt') + expect(resolvePasteReadySignal(null, true)).toBe('render-quiet-after-bracketed-paste') + }) +}) diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index 06fc76e2a834..ec455454de20 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -1,4 +1,5 @@ -import type { DraftPasteReadySignal } from './tui-agent-config' +import type { DraftPasteReadySignal, TuiAgentConfig } from './tui-agent-config' +import { createOpenCodeAgentRowScanner } from './opencode-agent-row-scanner' // Why: agents enable bracketed paste (DECSET 2004) before their composer is // actually mounted/focused. These markers let the scanner detect the real @@ -26,10 +27,6 @@ const GROK_COMPOSER_PROMPT = '❯' // means the composer specifically. Anchored on the alternate-screen switch for the same // reason as grok: a powerline shell prompt can also draw `╭`. const ZCODE_COMPOSER_BOX_CORNER = '╭' -// Why: the separator OpenCode paints between the agent and the model in the row under its input -// box. That row is drawn only once the agent list has loaded from OpenCode's server; until then -// its composer drops Enter silently, so the box alone is not ready. Structural, not a name. -const OPENCODE_AGENT_ROW_SEPARATOR = '\u00b7' const DECSET_ALT_SCREEN = '\x1b[?1049h' const DECRST_ALT_SCREEN = '\x1b[?1049l' @@ -44,15 +41,16 @@ type DraftPasteReadySignalSpec = { quietAnchor: string | null } -/** Signals that are ready only once every listed part has fired, in any order. */ -const ALL_OF_SIGNALS: Partial> = { - // Why both: OpenCode 2 shows its box cursor before the row; OpenCode 1 paints the row just - // before the cursor in the frame that draws the box, so waiting for both delays neither. - 'opencode-agent-row': ['render-cursor-after-bracketed-paste', 'opencode-agent-row-separator'] +// Signals that read the screen's structure, not one marker; a Record so none lacks its scanner. +type StructuralSignal = 'opencode-agent-row' +const STRUCTURAL_SCANNERS: Record< + StructuralSignal, + () => { observe: (data: string) => { ready: boolean; readyAfterMs: number | null } } +> = { + 'opencode-agent-row': createOpenCodeAgentRowScanner } -type CompositeSignal = 'opencode-agent-row' -type SingleSignal = Exclude | 'opencode-agent-row-separator' +type SingleSignal = Exclude const DRAFT_PASTE_READY_SIGNALS: Record = { 'codex-composer-prompt': { @@ -104,14 +102,6 @@ const DRAFT_PASTE_READY_SIGNALS: Record // switches to the alternate screen, where the marker anchor would never arm. quietAnchor: DECSET_BRACKETED_PASTE }, - 'opencode-agent-row-separator': { - // Why the alternate screen: a shell prompt can draw `·` after enabling bracketed paste, - // and OpenCode enters the alternate screen before it draws anything. - markerAnchor: DECSET_ALT_SCREEN, - markerAnchorEnd: DECRST_ALT_SCREEN, - marker: OPENCODE_AGENT_ROW_SEPARATOR, - quietAnchor: null - }, 'render-quiet-after-bracketed-paste': { markerAnchor: null, markerAnchorEnd: null, @@ -128,6 +118,22 @@ export type DraftPasteReadyScanResult = { ready: boolean /** Caller should (re)arm the quiet-window fallback timer for this chunk. */ armQuietTimer: boolean + /** Ready after this many ms unless the signal fires first; null withdraws an armed one. */ + readyAfterMs?: number | null +} + +export type DraftPasteReadyScanner = { observe: (data: string) => DraftPasteReadyScanResult } + +/** The signal to wait for before a paste; one that Enter follows waits for the submit signal. */ +export function resolvePasteReadySignal( + agentConfig: Pick | null, + submit: boolean +): DraftPasteReadySignal { + return ( + (submit ? agentConfig?.submitPasteReadySignal : undefined) ?? + agentConfig?.draftPasteReadySignal ?? + 'render-quiet-after-bracketed-paste' + ) } /** @@ -173,40 +179,31 @@ export type DraftPasteReadyScanResult = { * mounted — so the quiet window alone never settles and a launch draft would wait out * the whole hard timeout, exactly as grok did. Same alt-screen anchoring and * revocation as grok, because a powerline shell prompt can draw `╭` too. - * - `opencode-agent-row`: ready once both the box's show-cursor (as above) and the `·` - * between the agent and the model, after the alternate-screen switch, have rendered, - * i.e. once the row under the input box exists. OpenCode 2 mounts the box before its - * agent list arrives from its server and drops an Enter sent in that gap; OpenCode 1 - * paints the row in the frame that draws the box. No quiet window: the hard timeout - * is the backstop. + * - `opencode-agent-row`: OpenCode's submit signal (see opencode-agent-row-scanner.ts): the + * box's show-cursor as above plus the agent/model row painted directly above the box's + * bottom corner. OpenCode 2 draws the box before its agent list loads and drops an Enter + * sent in between. No quiet window; while the box shows without the row it asks for a + * grace timer (`readyAfterMs`) instead. * - `render-quiet-after-bracketed-paste` (default): no signal marker; arms the * quiet window once DECSET 2004 is seen. * * A 512-byte ring (`recent` / `postAnchorRecent`) covers escape sequences * split across chunk boundaries without retaining terminal scrollback. */ -export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal): { - observe: (data: string) => DraftPasteReadyScanResult -} { +export function createDraftPasteReadyScanner( + readySignal: DraftPasteReadySignal +): DraftPasteReadyScanner { if (isSingleSignal(readySignal)) { return createSingleSignalScanner(readySignal) } - const scanners = (ALL_OF_SIGNALS[readySignal] ?? []).map(createSingleSignalScanner) - const fired = scanners.map(() => false) + const scanner = STRUCTURAL_SCANNERS[readySignal]() return { - observe(data: string): DraftPasteReadyScanResult { - scanners.forEach((scanner, index) => { - fired[index] ||= scanner.observe(data).ready - }) - return { ready: fired.length > 0 && fired.every(Boolean), armQuietTimer: false } - } + observe: (data) => ({ ...scanner.observe(data), armQuietTimer: false }) } } -function isSingleSignal( - signal: DraftPasteReadySignal -): signal is Exclude { - return signal in DRAFT_PASTE_READY_SIGNALS +function isSingleSignal(signal: DraftPasteReadySignal): signal is SingleSignal { + return !(signal in STRUCTURAL_SCANNERS) } function createSingleSignalScanner(readySignal: SingleSignal): { @@ -345,7 +342,7 @@ function createSingleSignalScanner(readySignal: SingleSignal): { } // Why: the Codex glyph and opencode show-cursor signals must NOT arm the // quiet window (they carry no quiet anchor). opencode goes silent for - // Up to ~3.9s between enabling bracketed paste and mounting its composer, so a + // up to ~3.9s between enabling bracketed paste and mounting its composer, so a // quiet window would fire during that gap — before the composer exists — // and pre-empt the marker. Those signals wait for their marker, bounded // only by the caller's hard timeout (and its best-effort diff --git a/src/shared/opencode-agent-row-scanner.ts b/src/shared/opencode-agent-row-scanner.ts new file mode 100644 index 000000000000..01cbe3354074 --- /dev/null +++ b/src/shared/opencode-agent-row-scanner.ts @@ -0,0 +1,159 @@ +/** + * OpenCode's submit readiness: the ` · ` row along the bottom of its input box is + * painted only once its agent list has loaded from its server, and until then it drops an Enter. + * + * Read off the stream by structure, never by names or colours: the row's separator `·` (U+00B7) + * sits on the line directly above the box's bottom-left corner `╹` (U+2579), both placed by + * absolute cursor addressing inside OpenCode's alternate screen. The footer path below the box and + * the session tab strip above it can hold `·` too; neither is on that line. OpenCode 2 paints the + * corner first and the row in a later frame; OpenCode 1 paints the row just before the corner in + * the same synchronized frame, so a separator waits for its corner until that frame ends. + * + * Ready once the row exists and the box's cursor is shown while bracketed paste is held (the + * earlier box rule), so neither version is delivered to sooner than before. Leaving the alternate + * screen or turning bracketed paste off withdraws what it established. + */ + +const ESC = '\x1b' +const AGENT_ROW_SEPARATOR = '\u00b7' +const BOX_BOTTOM_LEFT = '\u2579' +// Why: a narrow pane never paints the row (OpenCode 2 drops the agent below 44 columns), and nor +// does a terminal that does not forward the alternate screen. Once the box's cursor has shown, the +// earlier box rule takes over after this grace, so those starts still get their task, as before. +// 5 s because the row trailed the box by at most 1.8 s in every recorded start, loaded or not. +export const OPENCODE_AGENT_ROW_GRACE_MS = 5_000 +// Longest unfinished escape carried into the next read; anything longer is noise, not a split. +const MAX_CARRIED_ESCAPE_CHARS = 512 + +/* oxlint-disable no-control-regex -- these match terminal escape sequences, which start with ESC */ +const CSI_RE = /^\x1b\[([?>=<]?)([0-9;]*)([ -/]*[@-~])/ +const UNFINISHED_CSI_RE = /^\x1b\[[?>=<]?[0-9;]*[ -/]*$/ +// OSC and DCS strings (titles, queries) end at BEL or ST; their text is never painted. +const STRING_RE = /^\x1b[\]P][^\x07\x1b]*(?:\x07|\x1b\\)/ +const STRING_START_RE = /^\x1b[\]P]/ +const STRING_END_RE = /\x07|\x1b\\/ + +export type OpenCodeAgentRowScan = { + /** The row exists and the box's cursor is shown: Enter will be taken. */ + ready: boolean + /** Box shown, no row yet: ready after this many ms unless the row comes first; null withdraws. */ + readyAfterMs: number | null +} + +export function createOpenCodeAgentRowScanner(): { + observe: (data: string) => OpenCodeAgentRowScan +} { + let carry = '' + let altScreen = false + let bracketedPaste = false + let boxCursorShown = false + let row: number | null = null + let boxBottomRow: number | null = null + let rowPainted = false + // Separator rows seen in this synchronized frame, for OpenCode 1's row-before-corner order. + let framePendingRows = new Set() + + const resetLayout = (): void => { + row = null + boxBottomRow = null + rowPainted = false + framePendingRows = new Set() + } + + const onPrivateMode = (mode: string, set: boolean): void => { + if (mode === '1049') { + altScreen = set + resetLayout() + } else if (mode === '2004') { + bracketedPaste = set + if (!set) { + boxCursorShown = false + } + } else if (mode === '25' && set && bracketedPaste) { + boxCursorShown = true + } else if (mode === '2026' && set) { + framePendingRows = new Set() + } + } + + const onCsi = (privateMarker: string, params: string, final: string): void => { + if (privateMarker === '?' && (final === 'h' || final === 'l')) { + for (const mode of params.split(';')) { + onPrivateMode(mode, final === 'h') + } + } else if (final === 'H' || final === 'f') { + row = Number(params.split(';')[0] || '1') + } else if (final === 'J' && params === '2') { + boxBottomRow = null + framePendingRows = new Set() + } + } + + const onText = (text: string): void => { + if (!altScreen || row === null) { + return + } + for (const char of text) { + if (char === '\n') { + row += 1 + } else if (char === AGENT_ROW_SEPARATOR) { + if (boxBottomRow === row + 1) { + rowPainted = true + } else { + framePendingRows.add(row) + } + } else if (char === BOX_BOTTOM_LEFT) { + boxBottomRow = row + if (framePendingRows.has(row - 1)) { + rowPainted = true + } + } + } + } + + return { + observe(data: string): OpenCodeAgentRowScan { + const input = carry + data + carry = '' + let index = 0 + while (index < input.length) { + const escapeAt = input.indexOf(ESC, index) + if (escapeAt === -1) { + onText(input.slice(index)) + break + } + onText(input.slice(index, escapeAt)) + const rest = input.slice(escapeAt) + const sequence = CSI_RE.exec(rest) ?? STRING_RE.exec(rest) + if (sequence) { + if (sequence[0][1] === '[') { + onCsi(sequence[1], sequence[2], sequence[3]) + } + index = escapeAt + sequence[0].length + continue + } + if (isUnfinishedEscape(rest)) { + carry = rest + break + } + index = escapeAt + 2 + } + // Leaving the alternate screen clears rowPainted, so it only holds while OpenCode is drawn. + return { + ready: rowPainted && boxCursorShown, + readyAfterMs: boxCursorShown && !rowPainted ? OPENCODE_AGENT_ROW_GRACE_MS : null + } + } + } +} + +function isUnfinishedEscape(rest: string): boolean { + if (rest.length > MAX_CARRIED_ESCAPE_CHARS) { + return false + } + return ( + rest === ESC || + UNFINISHED_CSI_RE.test(rest) || + (STRING_START_RE.test(rest) && !STRING_END_RE.test(rest)) + ) +} diff --git a/src/shared/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts index b4188549de8a..e0970fd75909 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -42,9 +42,11 @@ export type TuiAgentConfig = { preflightTrust?: 'claude' | 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' | 'qoder-cn' /** Agent-specific signal that the composer is ready for paste, stronger than the default quiet-render window. */ draftPasteReadySignal?: DraftPasteReadySignal + /** Signal before a paste that Enter follows, for agents that show their composer before it can submit; defaults to `draftPasteReadySignal`. */ + submitPasteReadySignal?: DraftPasteReadySignal /** Hard deadline for the agent's composer readiness signal. */ draftPasteReadyTimeoutMs?: number - /** Captured boots proving the composer marker used for fresh worker dispatch. */ + /** Captured boots proving the composer marker used for fresh worker dispatch (the submit signal where set). */ composerReadyCaptures?: readonly string[] /** Delay before one extra blind submit Enter, for agents that render their composer before Enter is live (codex); a no-op if the first Enter landed. */ submitRetryDelayMs?: number diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index a2ee87c02ef5..12c69db0de05 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -91,9 +91,11 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { opencode: { detectCmd: 'opencode', promptInjectionMode: 'flag-prompt', + // Why: opencode enables bracketed paste before its composer mounts; wait for the post-\x1b[?2004h show-cursor so paste lands. + draftPasteReadySignal: 'render-cursor-after-bracketed-paste', // Why: OpenCode 2 draws its input box before its agent list loads and drops an Enter sent // in between; the agent/model row under the box is the first moment it can submit. - draftPasteReadySignal: 'opencode-agent-row', + submitPasteReadySignal: 'opencode-agent-row', // Why 20s: measured on two Windows hosts (ConPTY dll backend, as pinned by // local-pty-utils), opencode does not enable bracketed paste until ~4.8s and its // composer is not ready until ~10s — so the 8s default expired first and the draft @@ -107,14 +109,14 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { 'opencode-cmd-2-0-21-timed-warm-server' ] }, - // Why: opencode2 installs as a separate binary and uses the same prompt flags and the same - // agent-row paste signal. + // Why: opencode2 installs as a separate binary and uses the same prompt flags and paste signals. opencode2: { detectCmd: 'opencode2', // The private server inherits this pane's hook endpoint and identity. launchCmd: 'opencode2 --standalone', promptInjectionMode: 'flag-prompt', - draftPasteReadySignal: 'opencode-agent-row', + draftPasteReadySignal: 'render-cursor-after-bracketed-paste', + submitPasteReadySignal: 'opencode-agent-row', draftPasteReadyTimeoutMs: 20_000, composerReadyCaptures: [ 'opencode-2-0-18-timed-boot-hidden-pane', @@ -127,7 +129,7 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { 'mimo-code': { detectCmd: 'mimo', promptInjectionMode: 'flag-prompt', - // Why: mirrors OpenCode's earlier cursor-gated signal by parity; mimo's startup stream isn't separately validated. + // Why: mirrors opencode's cursor-gated signal by parity; mimo's startup stream isn't separately validated. draftPasteReadySignal: 'render-cursor-after-bracketed-paste' }, pi: { diff --git a/src/shared/tui-agent-startup.test.ts b/src/shared/tui-agent-startup.test.ts index 0428f139e0eb..31690889e9f3 100644 --- a/src/shared/tui-agent-startup.test.ts +++ b/src/shared/tui-agent-startup.test.ts @@ -601,9 +601,10 @@ describe('tui agent startup plans', () => { expect(plan?.launchCommand).toBe("opencode --prompt 'fix it'") }) - it('keeps opencode on the agent-row and mimo-code on the cursor-gated paste draft route', () => { - expect(TUI_AGENT_CONFIG.opencode.draftPasteReadySignal).toBe('opencode-agent-row') - expect(TUI_AGENT_CONFIG.opencode2.draftPasteReadySignal).toBe('opencode-agent-row') + it('keeps opencode and mimo-code on the cursor-gated paste draft route', () => { + expect(TUI_AGENT_CONFIG.opencode.draftPasteReadySignal).toBe( + 'render-cursor-after-bracketed-paste' + ) expect(TUI_AGENT_CONFIG.opencode.draftPromptFlag).toBeUndefined() expect(TUI_AGENT_CONFIG.opencode.draftPromptEnvVar).toBeUndefined() expect(TUI_AGENT_CONFIG['mimo-code'].draftPasteReadySignal).toBe( From 719cc3d3062b10e8e4a3fb5d5c11b7f0c7e53b87 Mon Sep 17 00:00:00 2001 From: m4air Date: Sun, 4 Oct 2026 01:31:28 -0700 Subject: [PATCH 6/9] fix(opencode): take the box at the readiness deadline while the agent-row grace is pending - Both paste waiters resolve ready, not timed out, when the hard deadline lands while the 5 s grace is pending: the box was seen, so this is the verdict the box rule gave before. - Tab-bound draft paste is tested to wait on the agent row only when Enter follows. - Scanner: a separator pairs with its corner only inside one synchronized frame, row 0 clamps to 1, and the grace comment names the transparent-theme case. The draft signal type no longer admits the agent-row signal, whose grace only the paste waiters honour. Co-Authored-By: Claude --- ...runtime-worktree-startup-readiness.test.ts | 19 ++++++++++++++ .../runtime-worktree-startup-readiness.ts | 3 ++- .../src/lib/agent-draft-readiness.test.ts | 16 ++++++++++++ src/renderer/src/lib/agent-draft-readiness.ts | 3 ++- ...agent-paste-draft-readiness-budget.test.ts | 25 +++++++++++++++++-- src/shared/draft-paste-ready-scanner.test.ts | 11 ++++++++ src/shared/opencode-agent-row-scanner.ts | 9 ++++--- src/shared/tui-agent-config-types.ts | 9 ++++--- src/shared/tui-agent-config.ts | 1 + 9 files changed, 85 insertions(+), 11 deletions(-) diff --git a/src/main/runtime/runtime-worktree-startup-readiness.test.ts b/src/main/runtime/runtime-worktree-startup-readiness.test.ts index a6d6c5df431c..86b49fe5a7e7 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.test.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.test.ts @@ -146,6 +146,25 @@ describe('OpenCode submit readiness', () => { expect(vi.getTimerCount()).toBe(0) }) + it('takes the box at the deadline when the grace would outlast the budget', async () => { + vi.useFakeTimers() + const h = fixture() + const result = settle( + waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', { + timeoutMs: 60_000, + requireComposerMarker: true, + submit: true + }) + ) + await vi.advanceTimersByTimeAsync(59_000) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(999) + expect(result.value).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + expect(result.value).toBe('pty-1') + expect(vi.getTimerCount()).toBe(0) + }) + it('withdraws the grace when OpenCode turns bracketed paste off', async () => { vi.useFakeTimers() const h = fixture() diff --git a/src/main/runtime/runtime-worktree-startup-readiness.ts b/src/main/runtime/runtime-worktree-startup-readiness.ts index c0ca91fdeb68..95ff7226febf 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.ts @@ -148,7 +148,8 @@ export function waitForWorktreeStartupDraft( } unsubscribe = host.subscribeToData(ptyId, observe) hardTimer = setTimeout( - () => finish(null), + // A pending grace means the box was seen: take it, as the box rule would have. + () => finish(graceTimer ? ptyId : null), options.timeoutMs ?? resolveDraftPasteReadyTimeoutMs(agent) ) const replay = host.readRecentOutput(ptyId) diff --git a/src/renderer/src/lib/agent-draft-readiness.test.ts b/src/renderer/src/lib/agent-draft-readiness.test.ts index ae4ff400afaf..bfc11476ca60 100644 --- a/src/renderer/src/lib/agent-draft-readiness.test.ts +++ b/src/renderer/src/lib/agent-draft-readiness.test.ts @@ -74,6 +74,22 @@ describe('waitForAgentDraftInputReady', () => { expect(vi.getTimerCount()).toBe(0) }) + it('takes the box at the deadline when the grace would outlast the budget', async () => { + vi.useFakeTimers() + vi.stubGlobal('window', globalThis) + let settled: boolean | null = null + void waitForAgentDraftInputReady(PTY_ID, 20_000, 'opencode-agent-row', {}).then( + (ready) => (settled = ready) + ) + await vi.advanceTimersByTimeAsync(19_000) + testState.observer!(OPENCODE_BOX) + await vi.advanceTimersByTimeAsync(999) + expect(settled).toBeNull() + await vi.advanceTimersByTimeAsync(1) + expect(settled).toBe(true) + expect(vi.getTimerCount()).toBe(0) + }) + it('withdraws the grace when OpenCode turns bracketed paste off', async () => { vi.useFakeTimers() vi.stubGlobal('window', globalThis) diff --git a/src/renderer/src/lib/agent-draft-readiness.ts b/src/renderer/src/lib/agent-draft-readiness.ts index 867fde216e87..22e9c2e85356 100644 --- a/src/renderer/src/lib/agent-draft-readiness.ts +++ b/src/renderer/src/lib/agent-draft-readiness.ts @@ -97,7 +97,8 @@ export function waitForAgentDraftInputReady( } if (!settled) { - hardTimer = window.setTimeout(() => finish(false), timeoutMs) + // A pending grace means the box was seen: take it, as the box rule would have. + hardTimer = window.setTimeout(() => finish(graceTimer !== null), timeoutMs) } }) } 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 f9b4d6fbb66a..32b0b88c7530 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 @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { pasteDraftToAgentPtyWhenReady } from './agent-paste-draft' +import { pasteDraftToAgentPtyWhenReady, pasteDraftWhenAgentReady } from './agent-paste-draft' const testState = vi.hoisted(() => ({ waitForReady: vi.fn(), @@ -9,7 +9,8 @@ const testState = vi.hoisted(() => ({ vi.mock('@/store', () => ({ useAppStore: { - getState: () => ({ settings: {}, tabsByWorktree: {} }) + getState: () => ({ settings: {}, tabsByWorktree: {}, ptyIdsByTabId: { 'tab-1': ['pty-1'] } }), + subscribe: () => () => {} } })) @@ -114,6 +115,26 @@ describe('pty-bound agent draft readiness budget', () => { } ) + it.each([ + ['opencode', true, 'opencode-agent-row'], + ['opencode2', true, 'opencode-agent-row'], + ['opencode', false, 'render-cursor-after-bracketed-paste'], + ['opencode2', false, 'render-cursor-after-bracketed-paste'] + ] as const)('a tab-bound %s paste with submit %s waits on %s', async (agent, submit, signal) => { + const promise = pasteDraftWhenAgentReady({ + tabId: 'tab-1', + content: 'task', + agent, + submit, + forcePaste: true + }) + + await vi.advanceTimersByTimeAsync(12_000) + + await expect(promise).resolves.toBe(true) + expect(testState.waitForReady).toHaveBeenCalledWith('pty-1', 20_000, signal, {}) + }) + 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. diff --git a/src/shared/draft-paste-ready-scanner.test.ts b/src/shared/draft-paste-ready-scanner.test.ts index fba771abddeb..d991b7e7758d 100644 --- a/src/shared/draft-paste-ready-scanner.test.ts +++ b/src/shared/draft-paste-ready-scanner.test.ts @@ -156,6 +156,17 @@ describe('createDraftPasteReadyScanner', () => { ).toBe(true) }) + it('pairs a separator with a later corner only inside the same synchronized frame', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + const separatorFrame = frame('\x1b[23;33H\u00b7') + const cornerFrame = frame('\x1b[24;24H╹') + expect( + scanner.observe( + `${ALT_SCREEN_ENTER}${DECSET_BRACKETED_PASTE}${separatorFrame}${cornerFrame}` + ).ready + ).toBe(false) + }) + it('ignores a separator in the footer path under the box', () => { const scanner = createDraftPasteReadyScanner('opencode-agent-row') scanner.observe(BOX) diff --git a/src/shared/opencode-agent-row-scanner.ts b/src/shared/opencode-agent-row-scanner.ts index 01cbe3354074..d917ee74cd59 100644 --- a/src/shared/opencode-agent-row-scanner.ts +++ b/src/shared/opencode-agent-row-scanner.ts @@ -18,7 +18,8 @@ const ESC = '\x1b' const AGENT_ROW_SEPARATOR = '\u00b7' const BOX_BOTTOM_LEFT = '\u2579' // Why: a narrow pane never paints the row (OpenCode 2 drops the agent below 44 columns), and nor -// does a terminal that does not forward the alternate screen. Once the box's cursor has shown, the +// does a terminal that does not forward the alternate screen; a theme whose raised background is +// transparent paints no `╹`, so its starts always wait out the grace. Once the box's cursor has shown, the // earlier box rule takes over after this grace, so those starts still get their task, as before. // 5 s because the row trailed the box by at most 1.8 s in every recorded start, loaded or not. export const OPENCODE_AGENT_ROW_GRACE_MS = 5_000 @@ -50,7 +51,7 @@ export function createOpenCodeAgentRowScanner(): { let row: number | null = null let boxBottomRow: number | null = null let rowPainted = false - // Separator rows seen in this synchronized frame, for OpenCode 1's row-before-corner order. + // Separator rows seen in the open synchronized frame, for OpenCode 1's row-before-corner order. let framePendingRows = new Set() const resetLayout = (): void => { @@ -71,7 +72,7 @@ export function createOpenCodeAgentRowScanner(): { } } else if (mode === '25' && set && bracketedPaste) { boxCursorShown = true - } else if (mode === '2026' && set) { + } else if (mode === '2026') { framePendingRows = new Set() } } @@ -82,7 +83,7 @@ export function createOpenCodeAgentRowScanner(): { onPrivateMode(mode, final === 'h') } } else if (final === 'H' || final === 'f') { - row = Number(params.split(';')[0] || '1') + row = Math.max(1, Number(params.split(';')[0] || '1')) } else if (final === 'J' && params === '2') { boxBottomRow = null framePendingRows = new Set() diff --git a/src/shared/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts index e0970fd75909..587d4df84d75 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -6,14 +6,17 @@ export type AgentPromptInjectionMode = | 'hermes-query' | 'stdin-after-start' -export type DraftPasteReadySignal = +/** Signals a single marker settles; draft paste and the serializer read only these. */ +export type DraftPasteMarkerSignal = | 'render-quiet-after-bracketed-paste' | 'codex-composer-prompt' | 'render-cursor-after-bracketed-paste' | 'grok-composer-prompt' | 'dsh-composer-prompt' | 'zcode-composer-prompt' - | 'opencode-agent-row' + +/** Submit signals may also ask for a grace (`readyAfterMs`); only the paste waiters honour it. */ +export type DraftPasteReadySignal = DraftPasteMarkerSignal | 'opencode-agent-row' export type TuiAgentDetectionRuntime = NodeJS.Platform | 'wsl' @@ -41,7 +44,7 @@ export type TuiAgentConfig = { /** Trust Orca pre-writes at PTY spawn (agent-workspace-trust.ts) so the agent's first-launch "trust this folder?" menu doesn't consume the bracketed paste. */ preflightTrust?: 'claude' | 'cursor' | 'copilot' | 'codex' | 'antigravity' | 'qoder' | 'qoder-cn' /** Agent-specific signal that the composer is ready for paste, stronger than the default quiet-render window. */ - draftPasteReadySignal?: DraftPasteReadySignal + draftPasteReadySignal?: DraftPasteMarkerSignal /** Signal before a paste that Enter follows, for agents that show their composer before it can submit; defaults to `draftPasteReadySignal`. */ submitPasteReadySignal?: DraftPasteReadySignal /** Hard deadline for the agent's composer readiness signal. */ diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 12c69db0de05..84982cd9941f 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -4,6 +4,7 @@ import type { TuiAgentConfig } from './tui-agent-config-types' export type { AgentPromptInjectionMode, + DraftPasteMarkerSignal, DraftPasteReadySignal, TuiAgentConfig, TuiAgentDetectionRuntime From a89d9a5e4357662b23c3c17caf91c0712b4f7ca2 Mon Sep 17 00:00:00 2001 From: m4air Date: Sun, 4 Oct 2026 01:40:27 -0700 Subject: [PATCH 7/9] fix(opencode): withdraw the agent-row grace when OpenCode leaves the alternate screen If OpenCode exited after drawing its box but before its agent row, the grace still ran out and the task was pasted into the shell. Leaving the alternate screen now clears the box cursor, and once an app has left it a cursor the shell shows outside it no longer starts a grace. A terminal that never forwards the alternate screen still gets one. Co-Authored-By: Claude --- src/shared/draft-paste-ready-scanner.test.ts | 13 +++++++++++++ src/shared/opencode-agent-row-scanner.ts | 9 ++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/shared/draft-paste-ready-scanner.test.ts b/src/shared/draft-paste-ready-scanner.test.ts index d991b7e7758d..9383034064bf 100644 --- a/src/shared/draft-paste-ready-scanner.test.ts +++ b/src/shared/draft-paste-ready-scanner.test.ts @@ -209,6 +209,19 @@ describe('createDraftPasteReadyScanner', () => { ).toBe(false) }) + it('withdraws the grace when OpenCode leaves the alternate screen before its row', () => { + const scanner = createDraftPasteReadyScanner('opencode-agent-row') + expect(scanner.observe(BOX)).toEqual(GRACE) + const shellPrompt = `${DECSET_BRACKETED_PASTE}% ${SHOW_CURSOR}` + expect(scanner.observe(`${ALT_SCREEN_LEAVE}${shellPrompt}`)).toEqual({ + ready: false, + armQuietTimer: false, + readyAfterMs: null + }) + // A later OpenCode start in the same pane gets the grace again. + expect(scanner.observe(BOX)).toEqual(GRACE) + }) + it('withdraws readiness and the grace timer when bracketed paste is turned off', () => { const scanner = createDraftPasteReadyScanner('opencode-agent-row') expect(scanner.observe(BOX + AGENT_ROW).ready).toBe(true) diff --git a/src/shared/opencode-agent-row-scanner.ts b/src/shared/opencode-agent-row-scanner.ts index d917ee74cd59..ad5050031a5d 100644 --- a/src/shared/opencode-agent-row-scanner.ts +++ b/src/shared/opencode-agent-row-scanner.ts @@ -46,6 +46,8 @@ export function createOpenCodeAgentRowScanner(): { } { let carry = '' let altScreen = false + // Once a full-screen app has left, a box cursor outside the alternate screen is the shell's. + let leftAltScreen = false let bracketedPaste = false let boxCursorShown = false let row: number | null = null @@ -65,6 +67,10 @@ export function createOpenCodeAgentRowScanner(): { if (mode === '1049') { altScreen = set resetLayout() + if (!set) { + leftAltScreen = true + boxCursorShown = false + } } else if (mode === '2004') { bracketedPaste = set if (!set) { @@ -140,9 +146,10 @@ export function createOpenCodeAgentRowScanner(): { index = escapeAt + 2 } // Leaving the alternate screen clears rowPainted, so it only holds while OpenCode is drawn. + const graceApplies = boxCursorShown && !rowPainted && (altScreen || !leftAltScreen) return { ready: rowPainted && boxCursorShown, - readyAfterMs: boxCursorShown && !rowPainted ? OPENCODE_AGENT_ROW_GRACE_MS : null + readyAfterMs: graceApplies ? OPENCODE_AGENT_ROW_GRACE_MS : null } } } From 0ec73d9f180a9ca0f8c07dd4725cde27e9a2d5da Mon Sep 17 00:00:00 2001 From: m4air Date: Sun, 4 Oct 2026 14:38:22 -0700 Subject: [PATCH 8/9] test(serialize): register the new OpenCode captures' pre-existing grid-restore counts The six OpenCode 2 captures this PR adds hit the serializer limitations the 1.18.32 and 2.0.18 captures are already registered for: the live pen's background (and once its bold bit) on restored cells, rows kept below the source after a shrink, and the cursor one column short after a shrink. No I1 or I3 failure; the serializer is unchanged. Co-Authored-By: Claude --- src/main/daemon/serialize-grid-transcript-replay.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index 9a28f0a243aa..c7a87341c10d 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -40,6 +40,15 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record = { '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, + // STA-9359 OpenCode 2 captures, serializer untouched: every background diff is the live pen's + // panel colour on restored cells, plus the rows kept below the source after a shrink. + 'opencode-2-0-14-timed-cold-standalone': 4, + 'opencode-2-0-21-timed-cold-standalone': 36, + 'opencode-2-0-21-timed-cold-standalone-hidden-pane': 6, + 'opencode-2-0-21-timed-natural-load-enter-dropped': 12, + 'opencode-cmd-2-0-21-timed-warm-server': 21, + // The same, plus the cursor one column short after a shrink and the pen's bold bit on a glyph. + 'opencode-2-0-21-timed-narrow-pane': 8, // Shrink leaves the cursor one column short; also present in the pre-Qoder serializer. 'qoder-no-account': 2, 'qoder-ready': 2, From 391700ebad9a4618f72ad29ca969ffc7e8e09262 Mon Sep 17 00:00:00 2001 From: m4air Date: Mon, 5 Oct 2026 12:33:42 -0700 Subject: [PATCH 9/9] fix(opencode): bound the readiness deadline's last settle check With a grace pending, the deadline runs one more settle check, and over SSH its foreground read could hold a past-due wait for the channel's 30 s timeout. It now gives up after 2 s. Tests cover a hand-off, a failure and an abort during that check, a refused grace settled at the deadline, the bound, and the grace restarting at a hand-off. Co-Authored-By: Claude --- ...runtime-worktree-startup-readiness.test.ts | 108 +++++++++++++++++- .../runtime-worktree-startup-readiness.ts | 8 ++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/runtime-worktree-startup-readiness.test.ts b/src/main/runtime/runtime-worktree-startup-readiness.test.ts index 8500dbf30992..0155cb11d7d0 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.test.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.test.ts @@ -190,6 +190,7 @@ describe('OpenCode submit readiness', () => { accept?: () => boolean isShellInFront?: () => Promise timeoutMs?: number + signal?: AbortSignal } ) { return settle( @@ -198,7 +199,8 @@ describe('OpenCode submit readiness', () => { requireComposerMarker: true, submit: true, ...(checks.accept ? { accept: checks.accept } : {}), - ...(checks.isShellInFront ? { isShellInFront: checks.isShellInFront } : {}) + ...(checks.isShellInFront ? { isShellInFront: checks.isShellInFront } : {}), + ...(checks.signal ? { signal: checks.signal } : {}) }) ) } @@ -261,5 +263,109 @@ describe('OpenCode submit readiness', () => { await vi.advanceTimersByTimeAsync(8000 - OPENCODE_AGENT_ROW_GRACE_MS) expect(result.value).toBeNull() }) + + // A foreground read the test answers by hand, as an SSH host would. + function pendingRead() { + let answer = (_inFront: boolean): void => {} + let fail = (_error: Error): void => {} + const read = vi.fn( + () => + new Promise((resolve, reject) => { + answer = resolve + fail = reject + }) + ) + return { read, answer: (inFront: boolean) => answer(inFront), fail: (e: Error) => fail(e) } + } + + it('refuses the deadline when a shell hand-off lands during its check', async () => { + vi.useFakeTimers() + const h = fixture() + const check = pendingRead() + const result = waitWith(h, { isShellInFront: check.read, timeoutMs: 3000 }) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(3000) + h.emit('\x1b[?2004l') + check.answer(false) + await vi.advanceTimersByTimeAsync(0) + expect(result.value).toBeNull() + }) + + it('refuses the deadline when its check fails', async () => { + vi.useFakeTimers() + const h = fixture() + const check = pendingRead() + const result = waitWith(h, { isShellInFront: check.read, timeoutMs: 3000 }) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(3000) + check.fail(new Error('ssh channel closed')) + await vi.advanceTimersByTimeAsync(0) + expect(result.value).toBeNull() + }) + + it('resolves once, with null, when aborted during the deadline check', async () => { + vi.useFakeTimers() + const h = fixture() + const check = pendingRead() + const abort = new AbortController() + const pending = waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', { + timeoutMs: 3000, + requireComposerMarker: true, + submit: true, + isShellInFront: check.read, + signal: abort.signal + }) + const resolved = vi.fn() + void pending.then(resolved) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(3000) + abort.abort() + check.answer(false) + await vi.advanceTimersByTimeAsync(0) + expect(resolved.mock.calls).toEqual([[null]]) + expect(vi.getTimerCount()).toBe(0) + }) + + it('takes the box at the deadline after a refused grace once the agent is in front', async () => { + vi.useFakeTimers() + const h = fixture() + let shellInFront = true + const result = waitWith(h, { isShellInFront: async () => shellInFront, timeoutMs: 8000 }) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS) + expect(result.value).toBeUndefined() + shellInFront = false + await vi.advanceTimersByTimeAsync(8000 - OPENCODE_AGENT_ROW_GRACE_MS) + expect(result.value).toBe('pty-1') + }) + + it('gives up when the deadline check never answers', async () => { + vi.useFakeTimers() + const h = fixture() + const check = pendingRead() + const result = waitWith(h, { isShellInFront: check.read, timeoutMs: 3000 }) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(3000) + expect(check.read).toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1999) + expect(result.value).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + expect(result.value).toBeNull() + expect(vi.getTimerCount()).toBe(0) + }) + + it('restarts the grace at a hand-off rather than keeping the one from before it', async () => { + vi.useFakeTimers() + const h = fixture() + const result = waitWith(h, { isShellInFront: async () => false }) + h.emit(BOX) + await vi.advanceTimersByTimeAsync(3000) + // A hand-off whose chunk goes on to show a cursor under bracketed paste again. + h.emit('\x1b[?2004l\x1b[?2004h\x1b[?25h') + await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS - 1) + expect(result.value).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + expect(result.value).toBe('pty-1') + }) }) }) diff --git a/src/main/runtime/runtime-worktree-startup-readiness.ts b/src/main/runtime/runtime-worktree-startup-readiness.ts index 15a7c5c3a059..29f8d01d8b44 100644 --- a/src/main/runtime/runtime-worktree-startup-readiness.ts +++ b/src/main/runtime/runtime-worktree-startup-readiness.ts @@ -19,6 +19,9 @@ const BRACKETED_PASTE_QUIET_MS = 1500 // Why: an interactive shell turns bracketed paste on at its prompt and off when it runs the typed // command (`zsh-prompt-runs-command.txt`), so a 2004 before the last `?2004l` is the shell's. const DECRST_BRACKETED_PASTE = '\x1b[?2004l' +// Why: the deadline's last settle check is one foreground read, which over SSH could otherwise hold +// a past-due wait for the channel's 30 s timeout. +const DEADLINE_SETTLE_CHECK_MS = 2_000 export type WorktreeStartupReadinessHost = { getPtyId: (handle: string) => string | null @@ -131,6 +134,7 @@ export function waitForWorktreeStartupDraft( let graceTimer: NodeJS.Timeout | null = null let graceElapsed = false let hardTimer: NodeJS.Timeout | null = null + let deadlineCheckTimer: NodeJS.Timeout | null = null let unsubscribe: (() => void) | null = null // Bumped at each shell hand-off: a check begun before one must not settle the wait after it. let handoffs = 0 @@ -151,6 +155,9 @@ export function waitForWorktreeStartupDraft( if (hardTimer) { clearTimeout(hardTimer) } + if (deadlineCheckTimer) { + clearTimeout(deadlineCheckTimer) + } unsubscribe?.() options.signal?.removeEventListener('abort', onAbort) resolve(value) @@ -249,6 +256,7 @@ export function waitForWorktreeStartupDraft( return finish(null) } const deadlineHandoffs = handoffs + deadlineCheckTimer = setTimeout(() => finish(null), DEADLINE_SETTLE_CHECK_MS) void passesSettleChecks().then( (passes) => finish(passes && deadlineHandoffs === handoffs ? ptyId : null), () => finish(null)