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) } }