mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
fix(codex): keep working status when Escape closes search or permissions (#25769)
* fix(codex): preserve working status when Escape dismisses a view * test(codex): cover navigation Escape during terminal exit cleanup
This commit is contained in:
@@ -85,16 +85,22 @@ describe('Codex recorded turn interruption', () => {
|
||||
expect(server.getStatusSnapshot()[0]).toEqual(beforeSide)
|
||||
}
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: baseline.prompt,
|
||||
baselineAgentType: 'codex',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(false)
|
||||
for (const intent of ['ctrl-c', 'plain-escape'] as const) {
|
||||
for (const inputCount of [1, 2]) {
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: baseline.prompt,
|
||||
baselineAgentType: 'codex',
|
||||
intent,
|
||||
inputCount
|
||||
})
|
||||
).toBe(false)
|
||||
expect(server.getStatusSnapshot()[0]).toEqual(baseline)
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
expect(server.getStatusSnapshot()[0].state).toBe('working')
|
||||
appendFileSync(
|
||||
|
||||
@@ -27,7 +27,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('AgentHookServer listener replay', () => {
|
||||
it('keeps Codex lead state terminal after an inferred interrupt', () => {
|
||||
it('keeps Codex lead state terminal after a confirmed interrupt', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
@@ -50,19 +50,19 @@ describe('AgentHookServer listener replay', () => {
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
const applied = server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'long task',
|
||||
baselineAgentType: 'codex',
|
||||
intent: 'plain-escape'
|
||||
})
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
providerSession: { key: 'session_id', id: 'codex-interrupt-session-1' },
|
||||
hookEventName: 'Interrupt',
|
||||
payload: { state: 'done', prompt: 'long task', agentType: 'codex', interrupted: true }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(applied).toBe(true)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('AgentHookServer listener replay', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let late Codex tool hooks with explicit prompt resurrect an inferred interrupt', () => {
|
||||
it('does not let late Codex tool hooks with explicit prompt resurrect a confirmed interrupt', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
@@ -107,19 +107,22 @@ describe('AgentHookServer listener replay', () => {
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'Run sleep 30, then reply done.',
|
||||
baselineAgentType: 'codex',
|
||||
intent: 'plain-escape'
|
||||
})
|
||||
).toBe(true)
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
hookEventName: 'Interrupt',
|
||||
payload: {
|
||||
state: 'done',
|
||||
prompt: 'Run sleep 30, then reply done.',
|
||||
agentType: 'codex',
|
||||
interrupted: true
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
vi.setSystemTime(6_000)
|
||||
server.ingestRemote(
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
markClaudeLeadTurnInterrupted,
|
||||
clearClaudeAnsweredQuestionWait
|
||||
} from '../../../shared/agent-hook-listener/providers/claude-roster-state'
|
||||
import { markCodexLeadTurnInterrupted } from '../../../shared/agent-hook-listener/providers/codex-state'
|
||||
import {
|
||||
isAgentInterruptInputIntent,
|
||||
isNavigationEscapeIntent,
|
||||
@@ -81,13 +80,8 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO
|
||||
this.state.claudeActiveSessionCronPaneKeys.has(existing.paneKey)))
|
||||
// Why: a 'working' pane can be child-driven, and Ctrl+C at the idle prompt of a main agent that
|
||||
// child work holds open cancels nothing, so the main agent fact decides. A row from a host too
|
||||
// old to publish `mainAgent` keeps the evidence guard, and so does Codex: its synthesized row is
|
||||
// a plain done, which would retire the live children its combine keeps working.
|
||||
if (
|
||||
payload.mainAgent
|
||||
? payload.mainAgent.state !== 'working' || (agentType === 'codex' && childWorkEvidenced)
|
||||
: childWorkEvidenced
|
||||
) {
|
||||
// old to publish `mainAgent` keeps the evidence guard.
|
||||
if (payload.mainAgent ? payload.mainAgent.state !== 'working' : childWorkEvidenced) {
|
||||
return false
|
||||
}
|
||||
// Why: whoever owns the provider records folds the cancel with the child work the turn left
|
||||
@@ -101,9 +95,6 @@ export abstract class AgentHookServerStatusInference extends AgentHookServerRowO
|
||||
agentType === 'claude' && existing.connectionId
|
||||
? foldMainAgentWithRowChildWork('done', existing)
|
||||
: undefined
|
||||
if (agentType === 'codex') {
|
||||
markCodexLeadTurnInterrupted(this.state, existing.paneKey)
|
||||
}
|
||||
const state = local?.state ?? relayed?.stateName ?? 'done'
|
||||
const workingMode = local?.workingMode ?? relayed?.workingMode
|
||||
const inferred = this.applyNormalizedStatus({
|
||||
|
||||
@@ -56,6 +56,22 @@ it('forwards host-confirmed Codex interruption without requiring a local rollout
|
||||
expect(sideResponse.status).toBe(204)
|
||||
expect(forward).toHaveBeenCalledTimes(1)
|
||||
|
||||
const baseline = desktop.getStatusSnapshot()[0]
|
||||
for (const inputCount of [1, 2]) {
|
||||
expect(
|
||||
desktop.inferInterrupt({
|
||||
paneKey: PANE_KEY,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: baseline.prompt,
|
||||
baselineAgentType: 'codex',
|
||||
intent: 'plain-escape',
|
||||
inputCount
|
||||
})
|
||||
).toBe(false)
|
||||
expect(desktop.getStatusSnapshot()[0]).toEqual(baseline)
|
||||
}
|
||||
|
||||
appendFileSync(
|
||||
transcriptPath,
|
||||
line({ type: 'turn_aborted', turn_id: 'turn-1', reason: 'interrupted' })
|
||||
|
||||
@@ -14,7 +14,7 @@ function makeEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry
|
||||
prompt: 'write tests',
|
||||
updatedAt: 1_000,
|
||||
stateStartedAt: 900,
|
||||
agentType: 'codex',
|
||||
agentType: 'custom-agent',
|
||||
paneKey: PANE_KEY,
|
||||
terminalTitle: 'Codex',
|
||||
stateHistory: [],
|
||||
@@ -72,8 +72,7 @@ describe('agent interrupt inference', () => {
|
||||
|
||||
it.each([
|
||||
['plain-escape', 'gemini'],
|
||||
['ctrl-c', 'gemini'],
|
||||
['plain-escape', 'codex']
|
||||
['ctrl-c', 'gemini']
|
||||
] as const)('emits a strict baseline request for %s from %s immediately', (intent, agentType) => {
|
||||
vi.useFakeTimers()
|
||||
let entry: AgentStatusEntry | undefined = makeEntry({ agentType })
|
||||
@@ -321,8 +320,8 @@ describe('agent interrupt inference', () => {
|
||||
entry = undefined
|
||||
})
|
||||
|
||||
it.each([['claude'], ['omp'], ['pi'], ['prime-agent']] as const)(
|
||||
'never asks main to interrupt %s on a single Escape while working',
|
||||
it.each([['claude'], ['codex'], ['omp'], ['pi'], ['prime-agent']] as const)(
|
||||
'never asks main to interrupt %s on repeated navigation Escape while working',
|
||||
(agentType) => {
|
||||
// Why: Escape is ambiguous at the source for these TUIs, so the renderer does not spend a
|
||||
// round-trip on it. main re-checks the same rule for requests that never came from here.
|
||||
@@ -336,8 +335,10 @@ describe('agent interrupt inference', () => {
|
||||
now: () => 1_100
|
||||
})
|
||||
|
||||
tracker.observeInputIntent('plain-escape')
|
||||
tracker.observeInputIntent('plain-escape')
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(tracker.flushPending()).toBe(false)
|
||||
|
||||
expect(inferInterrupt).not.toHaveBeenCalled()
|
||||
tracker.dispose()
|
||||
@@ -493,7 +494,7 @@ describe('agent interrupt inference', () => {
|
||||
baselineUpdatedAt: 1_000,
|
||||
baselineStateStartedAt: 900,
|
||||
baselinePrompt: 'write tests',
|
||||
baselineAgentType: 'codex',
|
||||
baselineAgentType: 'custom-agent',
|
||||
intent: 'plain-escape'
|
||||
})
|
||||
tracker.dispose()
|
||||
@@ -580,7 +581,7 @@ describe('agent interrupt inference', () => {
|
||||
baselineUpdatedAt: 2_000,
|
||||
baselineStateStartedAt: 1_900,
|
||||
baselinePrompt: 'newer task',
|
||||
baselineAgentType: 'codex',
|
||||
baselineAgentType: 'custom-agent',
|
||||
intent: 'plain-escape'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -46,8 +46,7 @@ function shouldFlushInterruptImmediately(
|
||||
): boolean {
|
||||
return (
|
||||
requiresDoubleEscapeInterrupt(baseline.agentType, baseline.intent) ||
|
||||
baseline.agentType === 'gemini' ||
|
||||
(baseline.agentType === 'codex' && baseline.intent === 'plain-escape')
|
||||
baseline.agentType === 'gemini'
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+93
-82
@@ -747,96 +747,107 @@ describe('connectPanePty', () => {
|
||||
expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, paneKey)).toBe('alt-enter')
|
||||
})
|
||||
|
||||
it('pins interrupt inference before acknowledged input and command exit cleanup', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
it.each(['gemini', 'codex'] as const)(
|
||||
'pins %s interrupt policy before acknowledged input and command exit cleanup',
|
||||
async (agentType) => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
|
||||
const transport = createMockTransport()
|
||||
const writeAccepted = createDeferred<boolean>()
|
||||
transport.sendInputAccepted = vi.fn(() => writeAccepted.promise)
|
||||
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return { id: 'tab-pty' }
|
||||
})
|
||||
transport.attach.mockImplementation(({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_100)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: {
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: 'stop quickly',
|
||||
updatedAt: 1_000,
|
||||
stateStartedAt: 900,
|
||||
agentType: 'codex',
|
||||
terminalTitle: 'Codex',
|
||||
stateHistory: []
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
|
||||
const transport = createMockTransport()
|
||||
const writeAccepted = createDeferred<boolean>()
|
||||
transport.sendInputAccepted = vi.fn(() => writeAccepted.promise)
|
||||
transport.connect.mockImplementation(
|
||||
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return { id: 'tab-pty' }
|
||||
}
|
||||
}
|
||||
}
|
||||
vi.mocked(window.api.agentStatus.inferInterrupt).mockImplementation(async () => {
|
||||
mockStoreState.agentStatusByPaneKey[paneKey] = {
|
||||
paneKey,
|
||||
state: 'done',
|
||||
prompt: 'stop quickly',
|
||||
updatedAt: 1_100,
|
||||
stateStartedAt: 1_100,
|
||||
agentType: 'codex',
|
||||
terminalTitle: 'Codex',
|
||||
interrupted: true,
|
||||
stateHistory: [
|
||||
{
|
||||
)
|
||||
transport.attach.mockImplementation(({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
})
|
||||
transportFactoryQueue.push(transport)
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_100)
|
||||
const paneKey = makePaneKey('tab-1', LEAF_1)
|
||||
mockStoreState = {
|
||||
...mockStoreState,
|
||||
agentStatusByPaneKey: {
|
||||
[paneKey]: {
|
||||
paneKey,
|
||||
state: 'working',
|
||||
prompt: 'stop quickly',
|
||||
startedAt: 900
|
||||
updatedAt: 1_000,
|
||||
stateStartedAt: 900,
|
||||
agentType,
|
||||
terminalTitle: agentType,
|
||||
stateHistory: []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
const terminalTarget = createKeyboardEventTarget()
|
||||
const pane = createPane(1)
|
||||
;(pane.terminal as { element?: unknown }).element = terminalTarget.target
|
||||
let onDataHandler: ((data: string) => void) | null = null
|
||||
pane.terminal.onData = vi.fn(((handler: (data: string) => void) => {
|
||||
onDataHandler = handler
|
||||
return { dispose: vi.fn() }
|
||||
}) as typeof pane.terminal.onData)
|
||||
vi.mocked(window.api.agentStatus.inferInterrupt).mockImplementation(async () => {
|
||||
mockStoreState.agentStatusByPaneKey[paneKey] = {
|
||||
paneKey,
|
||||
state: 'done',
|
||||
prompt: 'stop quickly',
|
||||
updatedAt: 1_100,
|
||||
stateStartedAt: 1_100,
|
||||
agentType,
|
||||
terminalTitle: agentType,
|
||||
interrupted: true,
|
||||
stateHistory: [
|
||||
{
|
||||
state: 'working',
|
||||
prompt: 'stop quickly',
|
||||
startedAt: 900
|
||||
}
|
||||
]
|
||||
}
|
||||
return true
|
||||
})
|
||||
const terminalTarget = createKeyboardEventTarget()
|
||||
const pane = createPane(1)
|
||||
;(pane.terminal as { element?: unknown }).element = terminalTarget.target
|
||||
let onDataHandler: ((data: string) => void) | null = null
|
||||
pane.terminal.onData = vi.fn(((handler: (data: string) => void) => {
|
||||
onDataHandler = handler
|
||||
return { dispose: vi.fn() }
|
||||
}) as typeof pane.terminal.onData)
|
||||
|
||||
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
|
||||
vi.advanceTimersByTime(1_000)
|
||||
await flushAsyncTicks()
|
||||
expect(capturedDataCallback.current).not.toBeNull()
|
||||
if (!onDataHandler) {
|
||||
throw new Error('expected onData handler to be registered')
|
||||
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
|
||||
vi.advanceTimersByTime(1_000)
|
||||
await flushAsyncTicks()
|
||||
expect(capturedDataCallback.current).not.toBeNull()
|
||||
if (!onDataHandler) {
|
||||
throw new Error('expected onData handler to be registered')
|
||||
}
|
||||
terminalTarget.dispatch(keyEvent({ key: 'Escape' }))
|
||||
;(onDataHandler as unknown as (data: string) => void)('\x1b')
|
||||
|
||||
capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ')
|
||||
expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled()
|
||||
|
||||
delete mockStoreState.agentStatusByPaneKey[paneKey]
|
||||
writeAccepted.resolve(true)
|
||||
await flushAsyncTicks()
|
||||
|
||||
if (agentType === 'codex') {
|
||||
expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey)
|
||||
} else {
|
||||
expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({
|
||||
paneKey,
|
||||
baselineUpdatedAt: 1_000,
|
||||
baselineStateStartedAt: 900,
|
||||
baselinePrompt: 'stop quickly',
|
||||
baselineAgentType: agentType,
|
||||
intent: 'plain-escape'
|
||||
})
|
||||
expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey)
|
||||
}
|
||||
}
|
||||
terminalTarget.dispatch(keyEvent({ key: 'Escape' }))
|
||||
;(onDataHandler as unknown as (data: string) => void)('\x1b')
|
||||
|
||||
capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ')
|
||||
expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled()
|
||||
|
||||
delete mockStoreState.agentStatusByPaneKey[paneKey]
|
||||
writeAccepted.resolve(true)
|
||||
await flushAsyncTicks()
|
||||
|
||||
expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({
|
||||
paneKey,
|
||||
baselineUpdatedAt: 1_000,
|
||||
baselineStateStartedAt: 900,
|
||||
baselinePrompt: 'stop quickly',
|
||||
baselineAgentType: 'codex',
|
||||
intent: 'plain-escape'
|
||||
})
|
||||
expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey)
|
||||
})
|
||||
)
|
||||
|
||||
it('drops the command-finished status when pending interrupt inference is rejected', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
|
||||
@@ -26,12 +26,10 @@ export function shouldIgnoreInterruptIntent(
|
||||
return intent === 'ctrl-c' && (agentType === 'codex' || agentType === 'droid')
|
||||
}
|
||||
|
||||
// Why: these TUIs also close an overlay on a bare Escape (Claude's /btw composer, OMP/Pi's
|
||||
// focused-child and settings views). The keypress is ambiguous at the source and nothing outside
|
||||
// the TUI can disambiguate it, so it is never evidence a turn ended — only the provider's own
|
||||
// hook may retire the row (#13547, #9208). Ctrl+C is unaffected; it has no navigation meaning.
|
||||
// Escape also closes views (including Codex search and /permissions); only provider evidence ends the turn.
|
||||
const ESCAPE_ALSO_NAVIGATES_AGENT_TYPES: ReadonlySet<AgentType> = new Set([
|
||||
'claude',
|
||||
'codex',
|
||||
'omp',
|
||||
'pi',
|
||||
'prime-agent'
|
||||
|
||||
@@ -18,111 +18,129 @@ import {
|
||||
waitForActiveTerminalManager
|
||||
} from './helpers/terminal'
|
||||
|
||||
test('Codex Ctrl+C preserves working status until a confirmed interruption', async ({
|
||||
orcaPage,
|
||||
electronApp
|
||||
}, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const endpoint = await readHookEndpoint(electronApp)
|
||||
const descriptor = await waitForActivePaneHookDescriptor(orcaPage)
|
||||
await orcaPage.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
state?.setAgentActivityDisplayMode('full')
|
||||
if (state && !state.worktreeCardProperties.includes('inline-agents')) {
|
||||
state.setWorktreeCardProperties([...state.worktreeCardProperties, 'inline-agents'])
|
||||
}
|
||||
})
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-codex-interruption-'))
|
||||
const transcriptPath = join(dir, 'rollout-root.jsonl')
|
||||
writeFileSync(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({ type: 'event_msg', payload: { type: 'task_started', turn_id: 'turn-1' } })}\n`
|
||||
)
|
||||
try {
|
||||
const inputScript = join(dir, 'codex-input.cjs')
|
||||
// Replay navigation input through a real PTY; hook and rollout fixtures own agent status.
|
||||
for (const navigation of ['Control+c', 'F3 search', '/permissions'] as const) {
|
||||
test(`Codex ${navigation} preserves working status until a confirmed interruption`, async ({
|
||||
orcaPage,
|
||||
electronApp
|
||||
}, testInfo) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
const endpoint = await readHookEndpoint(electronApp)
|
||||
const descriptor = await waitForActivePaneHookDescriptor(orcaPage)
|
||||
await orcaPage.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
state?.setAgentActivityDisplayMode('full')
|
||||
if (state && !state.worktreeCardProperties.includes('inline-agents')) {
|
||||
state.setWorktreeCardProperties([...state.worktreeCardProperties, 'inline-agents'])
|
||||
}
|
||||
})
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-codex-interruption-'))
|
||||
const transcriptPath = join(dir, 'rollout-root.jsonl')
|
||||
writeFileSync(
|
||||
inputScript,
|
||||
`
|
||||
transcriptPath,
|
||||
`${JSON.stringify({ type: 'event_msg', payload: { type: 'task_started', turn_id: 'turn-1' } })}\n`
|
||||
)
|
||||
try {
|
||||
const inputScript = join(dir, 'codex-input.cjs')
|
||||
writeFileSync(
|
||||
inputScript,
|
||||
`
|
||||
process.stdin.setRawMode(true)
|
||||
process.stdin.resume()
|
||||
process.stdin.on('data', (chunk) => {
|
||||
if (chunk.includes(3)) process.stdout.write('ORCA_CTRL_C_RECEIVED\\n')
|
||||
if (chunk.toString() === '\\x1bOR' || chunk.toString() === '\\x1b[13~') {
|
||||
process.stdout.write('ORCA_F3_SEARCH_OPENED\\n')
|
||||
}
|
||||
if (chunk.includes(13)) process.stdout.write('ORCA_PERMISSIONS_OPENED\\n')
|
||||
if (chunk.toString() === '\\x1b') process.stdout.write('ORCA_ESCAPE_RECEIVED: view dismissed; main task continues\\n')
|
||||
})
|
||||
process.stdout.write('ORCA_CODEX_INPUT_READY\\n')
|
||||
`
|
||||
)
|
||||
const terminalWindowsShell = await orcaPage.evaluate(
|
||||
() => window.__store?.getState().settings.terminalWindowsShell
|
||||
)
|
||||
const shell = resolveStartupShell(
|
||||
process.platform,
|
||||
resolveLocalWindowsAgentStartupShell({
|
||||
platform: process.platform,
|
||||
isRemote: false,
|
||||
terminalWindowsShell
|
||||
)
|
||||
const terminalWindowsShell = await orcaPage.evaluate(
|
||||
() => window.__store?.getState().settings.terminalWindowsShell
|
||||
)
|
||||
const shell = resolveStartupShell(
|
||||
process.platform,
|
||||
resolveLocalWindowsAgentStartupShell({
|
||||
platform: process.platform,
|
||||
isRemote: false,
|
||||
terminalWindowsShell
|
||||
})
|
||||
)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
await execInTerminal(
|
||||
orcaPage,
|
||||
ptyId,
|
||||
buildShellCommandFromArgv([process.execPath, inputScript], shell)
|
||||
)
|
||||
await waitForTerminalOutput(orcaPage, 'ORCA_CODEX_INPUT_READY')
|
||||
await emitCodexHookStatus(endpoint, {
|
||||
...descriptor,
|
||||
transcriptPath,
|
||||
sessionId: 'main-session',
|
||||
state: 'working',
|
||||
prompt: 'Main task continues'
|
||||
})
|
||||
)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
await execInTerminal(
|
||||
orcaPage,
|
||||
ptyId,
|
||||
buildShellCommandFromArgv([process.execPath, inputScript], shell)
|
||||
)
|
||||
await waitForTerminalOutput(orcaPage, 'ORCA_CODEX_INPUT_READY')
|
||||
await emitCodexHookStatus(endpoint, {
|
||||
...descriptor,
|
||||
transcriptPath,
|
||||
sessionId: 'main-session',
|
||||
state: 'working',
|
||||
prompt: 'Main task continues'
|
||||
})
|
||||
const working = orcaPage.locator('[aria-label="Working"]')
|
||||
const interrupted = orcaPage.locator('[aria-label="Interrupted"]')
|
||||
await expect(working.first()).toBeVisible()
|
||||
await emitCodexHookStatus(endpoint, {
|
||||
...descriptor,
|
||||
state: 'working',
|
||||
sessionId: 'side-session',
|
||||
transcriptPath: null,
|
||||
prompt: 'Side chat'
|
||||
})
|
||||
await expect(orcaPage.getByText('Main task continues', { exact: true })).toBeVisible()
|
||||
await focusActiveTerminalInput(orcaPage)
|
||||
await orcaPage.keyboard.press('Control+c')
|
||||
await waitForTerminalOutput(orcaPage, 'ORCA_CTRL_C_RECEIVED')
|
||||
// Allow the old 500 ms inference timer to fire before recording the rendered result.
|
||||
await orcaPage.waitForTimeout(1_000)
|
||||
await orcaPage.screenshot({
|
||||
path: testInfo.outputPath('status-after-ctrl-c.png'),
|
||||
clip: { x: 0, y: 180, width: 280, height: 240 }
|
||||
})
|
||||
await expect(interrupted).toHaveCount(0)
|
||||
await expect(working.first()).toBeVisible()
|
||||
const working = orcaPage.locator('[aria-label="Working"]')
|
||||
const interrupted = orcaPage.locator('[aria-label="Interrupted"]')
|
||||
await expect(working.first()).toBeVisible()
|
||||
await emitCodexHookStatus(endpoint, {
|
||||
...descriptor,
|
||||
state: 'working',
|
||||
sessionId: 'side-session',
|
||||
transcriptPath: null,
|
||||
prompt: 'Side chat'
|
||||
})
|
||||
await expect(orcaPage.getByText('Main task continues', { exact: true })).toBeVisible()
|
||||
await focusActiveTerminalInput(orcaPage)
|
||||
const key = navigation === 'Control+c' ? 'Control+c' : 'Escape'
|
||||
if (navigation === 'F3 search') {
|
||||
await orcaPage.keyboard.press('F3')
|
||||
await waitForTerminalOutput(orcaPage, 'ORCA_F3_SEARCH_OPENED')
|
||||
} else if (navigation === '/permissions') {
|
||||
await orcaPage.keyboard.type('/permissions')
|
||||
await orcaPage.keyboard.press('Enter')
|
||||
await waitForTerminalOutput(orcaPage, 'ORCA_PERMISSIONS_OPENED')
|
||||
}
|
||||
await orcaPage.keyboard.press(key)
|
||||
await waitForTerminalOutput(
|
||||
orcaPage,
|
||||
key === 'Escape' ? 'ORCA_ESCAPE_RECEIVED' : 'ORCA_CTRL_C_RECEIVED'
|
||||
)
|
||||
// Allow the old 500 ms inference timer to fire before recording the rendered result.
|
||||
await orcaPage.waitForTimeout(1_000)
|
||||
await orcaPage.screenshot({
|
||||
path: testInfo.outputPath('status-after-input.png')
|
||||
})
|
||||
await expect(interrupted).toHaveCount(0)
|
||||
await expect(working.first()).toBeVisible()
|
||||
|
||||
appendFileSync(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({ type: 'event_msg', payload: { type: 'turn_aborted', turn_id: 'turn-1', reason: 'interrupted' } })}\n`
|
||||
)
|
||||
await expect(interrupted.first()).toBeVisible()
|
||||
await expect(working).toHaveCount(0)
|
||||
await orcaPage.screenshot({
|
||||
path: testInfo.outputPath('status-after-confirmed-interruption.png'),
|
||||
clip: { x: 0, y: 180, width: 280, height: 240 }
|
||||
})
|
||||
await emitCodexHookStatus(endpoint, {
|
||||
...descriptor,
|
||||
state: 'working',
|
||||
prompt: 'Next main task'
|
||||
})
|
||||
await expect(working.first()).toBeVisible()
|
||||
await expect(interrupted).toHaveCount(0)
|
||||
await emitCodexHookStatus(endpoint, { ...descriptor, state: 'done' })
|
||||
await expect(working).toHaveCount(0)
|
||||
await expect(interrupted).toHaveCount(0)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
appendFileSync(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({ type: 'event_msg', payload: { type: 'turn_aborted', turn_id: 'turn-1', reason: 'interrupted' } })}\n`
|
||||
)
|
||||
await expect(interrupted.first()).toBeVisible()
|
||||
await expect(working).toHaveCount(0)
|
||||
await orcaPage.screenshot({
|
||||
path: testInfo.outputPath('status-after-confirmed-interruption.png')
|
||||
})
|
||||
await emitCodexHookStatus(endpoint, {
|
||||
...descriptor,
|
||||
state: 'working',
|
||||
prompt: 'Next main task'
|
||||
})
|
||||
await expect(working.first()).toBeVisible()
|
||||
await expect(interrupted).toHaveCount(0)
|
||||
await emitCodexHookStatus(endpoint, { ...descriptor, state: 'done' })
|
||||
await expect(working).toHaveCount(0)
|
||||
await expect(interrupted).toHaveCount(0)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user