From b42fcd8a00f083ecad77fe9bf548b11832089be0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:06:42 -0700 Subject: [PATCH] fix(runtime): retract Orca's own Codex ready title when the next turn starts (#35547) * fix(runtime): retract Orca's own Codex ready title when the next turn starts A Codex that paints no working title of its own (its activity title off) kept the "Codex ready" frame Orca writes on its Stop hook for the whole next turn, so tui-idle waits, mailbox delivery and the tab all read it as at rest. The working hook now replaces Orca's own rest label with one working frame, as #29429 did for agents whose profiles synthesize working titles. Tracked in STA-12718. * test: cover titleless turn wait and mailbox lifecycle * test: type title recovery write spy * test: isolate CLI lifecycle exit state across runtimes --------- Co-authored-by: Codex QA --- .../stale-title-completion-recovery.spec.ts | 256 +++++++++++++++++- .../output/terminal-transient-facts.ts | 5 + .../synthetic-title-runtime-headless.test.ts | 28 +- src/main/startup/synthetic-title-runtime.ts | 32 ++- 4 files changed, 311 insertions(+), 10 deletions(-) diff --git a/src/main/runtime/stale-title-completion-recovery.spec.ts b/src/main/runtime/stale-title-completion-recovery.spec.ts index 5897a0beace..ac1436d0a13 100644 --- a/src/main/runtime/stale-title-completion-recovery.spec.ts +++ b/src/main/runtime/stale-title-completion-recovery.spec.ts @@ -1,20 +1,90 @@ -import { rmSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' +import { extractAllOscTitleEntries } from '../../shared/osc-title-extraction' import { settledWriteStub } from '../providers/settled-pty-write-stub' +import { paneKeyPtyId, ptyPaneKey, rememberPaneKeyForPty } from '../ipc/pty/pane/key-state' +import { mainProcessState } from '../startup/main-process-state' +import { driveSyntheticTitleFromHookStatus } from '../startup/synthetic-title-runtime' +import { createLongPollServer } from './runtime-rpc-long-poll-server.test-fixture' import { createBoundRun, createDatabase, createRuntime, insertDirectRunMessage, + LEAF_ID, PANE_KEY, PTY_ID, + TAB_ID, TERMINAL_HANDLE, - temporaryDirectories + temporaryDirectories, + WORKTREE_ID } from './orchestration-mailbox-notification-test-harness' -describe('stale-working title clear and completion recovery', () => { +function producerFixture(agentType: 'codex' | 'pi' = 'codex') { + const db = createDatabase('orca-hook-title-contract-') + const hook: AgentStatusIpcPayload = { + paneKey: PANE_KEY, + terminalHandle: TERMINAL_HANDLE, + agentType, + state: 'done', + prompt: '', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now() + } + const { runtime } = createRuntime(db, { + launchAgent: agentType, + getAgentStatusSnapshot: () => [hook] + }) + const write = vi.fn((_ptyId: string, _data: string) => true) + runtime.setPtyController({ + write, + writeWithSettlement: settledWriteStub(write), + kill: vi.fn(), + getForegroundProcess: async () => agentType + }) + const previousRuntime = mainProcessState.runtime + const previousPaneKey = ptyPaneKey.get(PTY_ID) + const previousPtyId = paneKeyPtyId.get(PANE_KEY) + const previousOtherPtyId = previousPaneKey ? paneKeyPtyId.get(previousPaneKey) : undefined + mainProcessState.runtime = runtime + rememberPaneKeyForPty(PTY_ID, PANE_KEY) + const status = (state: 'done' | 'working') => { + Object.assign(hook, { state, receivedAt: Date.now(), stateStartedAt: Date.now() }) + driveSyntheticTitleFromHookStatus({ paneKey: PANE_KEY, payload: hook }) + } + const dispose = async () => { + try { + await runtime.onPtyExit(PTY_ID, 0) + } finally { + db.close() + mainProcessState.runtime = previousRuntime + if (previousPaneKey === undefined) { + ptyPaneKey.delete(PTY_ID) + } else { + ptyPaneKey.set(PTY_ID, previousPaneKey) + } + if (previousPtyId === undefined) { + paneKeyPtyId.delete(PANE_KEY) + } else { + paneKeyPtyId.set(PANE_KEY, previousPtyId) + } + if (previousPaneKey && previousPaneKey !== PANE_KEY && previousOtherPtyId !== undefined) { + paneKeyPtyId.set(previousPaneKey, previousOtherPtyId) + } + } + } + return { db, runtime, write, status, dispose } +} + +describe('hook title evidence and completion recovery', () => { afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() vi.useRealTimers() for (const directory of temporaryDirectories.splice(0)) { rmSync(directory, { recursive: true, force: true }) @@ -59,4 +129,184 @@ describe('stale-working title clear and completion recovery', () => { ]) db.close() }) + + it('CLI waits through a silent titleless turn and delivers mail only after Stop', async () => { + const { db, runtime, write, status, dispose } = producerFixture() + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-title-wait-cli-')) + temporaryDirectories.push(userDataPath) + const server = createLongPollServer({ runtime, userDataPath, enableWebSocket: false }) + const exitCode = process.exitCode + for (const name of ['ORCA_AGENT_SESSION_ID', 'ORCA_ENVIRONMENT', 'ORCA_PAIRING_CODE']) { + vi.stubEnv(name, '') + } + vi.stubEnv('ORCA_USER_DATA_PATH', userDataPath) + vi.stubEnv('ORCA_TERMINAL_HANDLE', TERMINAL_HANDLE) + const output = vi.spyOn(console, 'log').mockImplementation(() => {}) + try { + // The source CLI has its own TS project; keep the real client import at the runtime boundary. + const cliPath = '../../cli/index' + const { main }: { main(argv: string[], cwd: string): Promise } = await import(cliPath) + const wait = async () => { + output.mockClear() + process.exitCode = 0 + await main( + [ + 'terminal', + 'wait', + '--terminal', + TERMINAL_HANDLE, + '--for', + 'tui-idle', + '--timeout-ms', + '300', + '--json' + ], + userDataPath + ) + return JSON.parse(String(output.mock.calls.at(-1)?.[0])) + } + await server.start() + const run = createBoundRun(db, 'Titleless Run') + await runtime.listTerminals() + status('done') + await expect( + runtime.sendTerminal( + TERMINAL_HANDLE, + { text: 'next prompt', enter: true }, + { inputKind: 'driving' } + ) + ).resolves.toMatchObject({ accepted: true }) + write.mockClear() + status('working') + insertDirectRunMessage(db, run.id, 'Worker progress') + runtime.notifyMessageArrived(`run:${run.id}`, 'status') + runtime.onPtyData(PTY_ID, 'working without native titles\r\n', 1) + // Real silence lets the production display timer fire; no output resets it. + await new Promise((resolve) => setTimeout(resolve, 3_200)) + expect((await runtime.listTerminals()).terminals[0]?.title).toBe('Codex') + expect(runtime.getTrackedPtyTitle(PTY_ID)).toBe('⠋ Codex') + expect(await wait()).toMatchObject({ ok: false, error: { code: 'timeout' } }) + expect(process.exitCode).toBe(1) + expect(write).not.toHaveBeenCalled() + + status('done') + expect(await wait()).toMatchObject({ ok: true, result: { wait: { satisfied: true } } }) + expect(process.exitCode).toBe(0) + await expect + .poll(() => write.mock.calls.map(([, data]) => data)) + .toEqual([expect.stringContaining('You have 1 orchestration message'), '\r']) + runtime.notifyMessageArrived(`run:${run.id}`, 'status') + await new Promise((resolve) => setTimeout(resolve, 500)) + expect(write).toHaveBeenCalledTimes(2) + } finally { + process.exitCode = exitCode ?? 0 + try { + await server.stop() + } finally { + await dispose() + } + } + }, 15_000) + + it('Pi controlled hooks retract rest and preserve a subsequently recorded native title', async () => { + vi.useFakeTimers() + const { runtime, status, dispose } = producerFixture('pi') + try { + // Pi 0.74.0/Linux capture, 2026-10-16; adjacent .meta.json records provenance. + const capture = readFileSync( + new URL('./__fixtures__/pi-0-74-late-kitty-esc-cr.txt', import.meta.url), + 'utf8' + ) + const nativeTitle = extractAllOscTitleEntries(capture).find(({ title }) => + title.startsWith('π') + ) + if (!nativeTitle) { + throw new Error('Pi capture must contain its native OSC title') + } + const nativeFrame = capture.slice( + capture.lastIndexOf('\x1b]', nativeTitle.end - 1), + nativeTitle.end + ) + await runtime.listTerminals() + status('done') + expect(runtime.getTrackedPtyTitle(PTY_ID)).toBe('Pi ready') + status('working') + expect(runtime.getTrackedPtyTitle(PTY_ID)).toBe('⠋ Pi') + await expect(runtime.getTerminalAgentStatus(TERMINAL_HANDLE)).resolves.toMatchObject({ + status: 'working' + }) + runtime.onPtyData(PTY_ID, nativeFrame, 1) + expect(runtime.getTrackedPtyTitle(PTY_ID)).toBe('π - work') + status('working') + expect(runtime.getTrackedPtyTitle(PTY_ID)).toBe('π - work') + } finally { + await dispose() + } + }) + + it.each([20, 200])( + 'bounds semantic ingestion and snapshot tab visits at %i tabs', + async (population) => { + vi.useFakeTimers() + const { runtime, status, dispose } = producerFixture() + try { + await runtime.listTerminals() + status('done') + await runtime.getTerminalAgentStatus(TERMINAL_HANDLE) + await vi.advanceTimersByTimeAsync(1_000) + runtime.mobileSessionTabsByWorktree.clear() + let visits = 0 + for (const [group, worktree] of [WORKTREE_ID, 'unrelated-worktree'].entries()) { + const tabs: RuntimeMobileSessionTabsSnapshot['tabs'] = Array.from( + { length: population / 2 }, + (_, index) => { + const target = group === 0 && index === population / 2 - 1 + return { + type: 'terminal', + id: target ? `${TAB_ID}::${LEAF_ID}` : `tab-${group}-${index}`, + parentTabId: target ? TAB_ID : `parent-${group}-${index}`, + leafId: target ? LEAF_ID : `leaf-${index}`, + title: 'Codex', + ptyId: target ? PTY_ID : `other-${group}-${index}`, + isActive: false + } + } + ) + const some = tabs.some.bind(tabs) + vi.spyOn(tabs, 'some').mockImplementation((predicate, thisArg) => + some((tab, index, array) => { + visits += 1 + return predicate.call(thisArg, tab, index, array) + }) + ) + runtime.mobileSessionTabsByWorktree.set(worktree, { + worktree, + publicationEpoch: 'title-contract', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs + }) + } + const ingestion = vi.spyOn(runtime, 'ingestSyntheticTitleFrame') + status('working') + await vi.advanceTimersByTimeAsync(1_000) + expect(ingestion).toHaveBeenCalledTimes(1) + expect(ingestion).toHaveBeenCalledWith(PTY_ID, '\x1b]0;⠋ Codex\x07') + expect(visits).toBeGreaterThanOrEqual(population) + expect(visits).toBeLessThanOrEqual(4 * population) + ingestion.mockClear() + visits = 0 + for (let event = 0; event < 100; event += 1) { + status('working') + } + await vi.advanceTimersByTimeAsync(1_000) + expect(ingestion).not.toHaveBeenCalled() + expect(visits).toBe(0) + } finally { + await dispose() + } + } + ) }) diff --git a/src/main/runtime/terminal/output/terminal-transient-facts.ts b/src/main/runtime/terminal/output/terminal-transient-facts.ts index 5006fcb20d6..2ab9d59dbf7 100644 --- a/src/main/runtime/terminal/output/terminal-transient-facts.ts +++ b/src/main/runtime/terminal/output/terminal-transient-facts.ts @@ -190,6 +190,11 @@ export abstract class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRunt return null } + /** The same title for main's synthetic title producer, which must see its own frames. */ + getTrackedPtyTitle(ptyId: string): string | null { + return this.getTrackedRawTitleForPty(ptyId) + } + protected isLiveCursorNativeTitle(rawTitle: string, meta?: TerminalTitleFactMeta): boolean { return isCursorNativeAgentTitle(rawTitle) && meta?.staleWorkingTitleClear !== true } diff --git a/src/main/startup/synthetic-title-runtime-headless.test.ts b/src/main/startup/synthetic-title-runtime-headless.test.ts index f42f8031490..bce3e2a6038 100644 --- a/src/main/startup/synthetic-title-runtime-headless.test.ts +++ b/src/main/startup/synthetic-title-runtime-headless.test.ts @@ -6,7 +6,7 @@ type EnrichedListener = (event: Parameters { const enriched: EnrichedListener[] = [] - return { enriched, ingest: vi.fn() } + return { enriched, ingest: vi.fn(), trackedTitle: vi.fn((): string | null => null) } }) vi.mock('../ipc/pty/pane/key-state', () => ({ @@ -37,11 +37,13 @@ function hermesStatus(state: 'working' | 'done'): Parameters[0 beforeEach(() => { hooks.enriched.length = 0 hooks.ingest.mockReset() + hooks.trackedTitle.mockReset() mainProcessState.mainWindow = null mainProcessState.store = null - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: sendSyntheticTitle reads only ingestSyntheticTitleFrame from the runtime. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the producer reads only these two members of the runtime. mainProcessState.runtime = { - ingestSyntheticTitleFrame: hooks.ingest + ingestSyntheticTitleFrame: hooks.ingest, + getTrackedPtyTitle: hooks.trackedTitle } as unknown as typeof mainProcessState.runtime }) @@ -73,4 +75,24 @@ describe('synthetic agent titles without a window', () => { expect(hooks.ingest).not.toHaveBeenCalled() }) + + // Codex leaves working titles to itself, so a Codex that paints none kept "ready" all turn. + it.each([ + { title: 'Codex ready', retracted: true }, + { title: 'Codex - action required', retracted: true }, + { title: '⠋ Fix the build | orca', retracted: false } + ])( + 'retracts only its own rest label when Codex starts a turn ($title)', + ({ title, retracted }) => { + hooks.trackedTitle.mockReturnValue(title) + driveSyntheticTitleFromHookStatus({ + paneKey: 'pane-1', + payload: { state: 'working', agentType: 'codex' } + }) + + expect(hooks.ingest.mock.calls.map(([, frame]) => frame)).toEqual( + retracted ? ['\x1b]0;⠋ Codex\x07'] : [] + ) + } + ) }) diff --git a/src/main/startup/synthetic-title-runtime.ts b/src/main/startup/synthetic-title-runtime.ts index a4515a96b2f..b75f7c58763 100644 --- a/src/main/startup/synthetic-title-runtime.ts +++ b/src/main/startup/synthetic-title-runtime.ts @@ -1,6 +1,7 @@ import { registerPaneKeyTeardownListener, getPtyIdForPaneKey } from '../ipc/pty/pane/key-state' import { agentHookServer } from '../agent-hooks/server' import type { AgentStatusState } from '../../shared/agent-status-types' +import { normalizeTerminalTitle } from '../../shared/agent-title-status' import { getSyntheticAgentTitleProfile, shouldDriveSyntheticAgentTitleFromHook, @@ -148,6 +149,27 @@ export function driveSyntheticTitleFromHook( sendSyntheticTitle(ptyId, `\x1b]0;${label}\x07${needsUserInput ? '\x07' : ''}`, { force: true }) } +// Why: a profile that leaves working titles to the agent never overwrites the rest label Orca +// wrote, so an agent that paints no working title (Codex with its activity title off) would read +// as at rest for the whole next turn, to tui-idle, mailbox delivery and the tab alike. +function retractSyntheticRestTitle(paneKey: string, profile: SyntheticAgentTitleProfile): void { + const ptyId = getPtyIdForPaneKey(paneKey) + const title = ptyId ? state.runtime?.getTrackedPtyTitle(ptyId) : null + if ( + !ptyId || + !title || + ![profile.idleLabel, profile.permissionLabel].some( + (label) => normalizeTerminalTitle(label) === title + ) + ) { + return + } + // One frame and no spinner timer, so the agent's own titles still win whenever it paints them. + sendSyntheticTitle(ptyId, `\x1b]0;${SPINNER_FRAMES[0]} ${profile.workingLabel}\x07`, { + semantic: true + }) +} + export function initializeSyntheticTitleRuntime(): void { // Drop torn-down PTYs explicitly or the shared timer loops forever on no-op sends. registerPaneKeyTeardownListener((paneKey) => stopSyntheticTitleSpinner(paneKey)) @@ -175,10 +197,12 @@ export function driveSyntheticTitleFromHookStatus(event: { } // Inject hook-derived idle/permission frames that native OSC titles can omit. const profile = getSyntheticAgentTitleProfile(event.payload.agentType) - if ( - profile && - shouldDriveSyntheticAgentTitleFromHook(event.payload.agentType, event.payload.state) - ) { + if (!profile) { + return + } + if (shouldDriveSyntheticAgentTitleFromHook(event.payload.agentType, event.payload.state)) { driveSyntheticTitleFromHook(event.paneKey, event.payload.state, profile) + } else if (event.payload.state === 'working' && profile.synthesizeTerminalTitle !== false) { + retractSyntheticRestTitle(event.paneKey, profile) } }