Harden agent interrupt inference

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-05-18 20:57:51 -07:00
co-authored by Orca
parent 03b4f86759
commit bb8cacd984
30 changed files with 1742 additions and 138 deletions
+355
View File
@@ -360,6 +360,361 @@ describe('AgentHookServer listener replay', () => {
}
})
it('allows an immediate same-prompt retry after an inferred interrupt', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'retryable task', agentType: 'pi' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'retryable task',
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(true)
vi.setSystemTime(2_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'retryable task', agentType: 'pi' }
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
prompt: 'retryable task',
agentType: 'pi',
receivedAt: 2_000,
stateStartedAt: 2_000
})
])
} finally {
vi.useRealTimers()
}
})
it('allows a same-prompt working hook after the stale suppression window', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'repeat task', agentType: 'pi' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'repeat task',
baselineAgentType: 'pi',
intent: 'ctrl-c'
})
).toBe(true)
vi.setSystemTime(16_501)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
state: 'working',
prompt: 'repeat task',
agentType: 'pi',
toolName: 'bash',
toolInput: '/bin/sleep 90'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
prompt: 'repeat task',
agentType: 'pi',
receivedAt: 16_501,
stateStartedAt: 16_501
})
])
} finally {
vi.useRealTimers()
}
})
it('rejects malformed inferred interrupt requests without throwing', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'long task', agentType: 'codex' }
},
'conn-1'
)
const malformed: unknown[] = [
{
paneKey: 'tab-1:0',
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 1_000,
baselinePrompt: 'long task',
baselineAgentType: 'codex',
intent: 'ctrl-c'
},
{
paneKey: PANE,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 1_000,
baselinePrompt: 'long task',
baselineAgentType: 'codex',
intent: 'sigint'
},
{
paneKey: PANE,
baselineUpdatedAt: '1_000',
baselineStateStartedAt: 1_000,
baselinePrompt: 'long task',
baselineAgentType: 'codex',
intent: 'ctrl-c'
},
{
paneKey: PANE,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 1_000,
baselinePrompt: 123,
baselineAgentType: 'codex',
intent: 'ctrl-c'
}
]
for (const request of malformed) {
expect(() =>
server.inferInterrupt(request as Parameters<AgentHookServer['inferInterrupt']>[0])
).not.toThrow()
expect(
server.inferInterrupt(request as Parameters<AgentHookServer['inferInterrupt']>[0])
).toBe(false)
}
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
prompt: 'long task',
agentType: 'codex'
})
])
} finally {
vi.useRealTimers()
}
})
it('allows an immediate same-prompt retry that carries cached turn detail', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: {
state: 'working',
prompt: 'retryable task',
agentType: 'opencode',
lastAssistantMessage: 'partial answer'
}
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'retryable task',
baselineAgentType: 'opencode',
intent: 'ctrl-c'
})
).toBe(true)
vi.setSystemTime(2_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: {
state: 'working',
prompt: 'retryable task',
agentType: 'opencode',
lastAssistantMessage: 'partial answer'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'working',
prompt: 'retryable task',
agentType: 'opencode',
lastAssistantMessage: 'partial answer',
receivedAt: 2_000,
stateStartedAt: 2_000
})
])
} finally {
vi.useRealTimers()
}
})
it('suppresses replayed same-prompt working events after an inferred interrupt', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: {
state: 'working',
prompt: 'retryable task',
agentType: 'opencode',
lastAssistantMessage: 'partial answer'
}
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'retryable task',
baselineAgentType: 'opencode',
intent: 'ctrl-c'
})
).toBe(true)
vi.setSystemTime(20_000)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
isReplay: true,
payload: {
state: 'working',
prompt: 'retryable task',
agentType: 'opencode',
lastAssistantMessage: 'partial answer'
}
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'done',
prompt: 'retryable task',
agentType: 'opencode',
interrupted: true,
receivedAt: 1_500,
stateStartedAt: 1_500
})
])
} finally {
vi.useRealTimers()
}
})
it('matches renderer unknown sentinel to an omitted hook agent type', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
payload: { state: 'working', prompt: 'custom hook' }
},
'conn-1'
)
const baseline = server.getStatusSnapshot()[0]
vi.setSystemTime(1_500)
expect(
server.inferInterrupt({
paneKey: PANE,
baselineUpdatedAt: baseline.receivedAt,
baselineStateStartedAt: baseline.stateStartedAt,
baselinePrompt: 'custom hook',
baselineAgentType: 'unknown',
intent: 'ctrl-c'
})
).toBe(true)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'done',
prompt: 'custom hook',
interrupted: true
})
])
} finally {
vi.useRealTimers()
}
})
it('rejects inferred interrupts for stale and non-working rows', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
+18 -2
View File
@@ -113,6 +113,15 @@ type LastStatusFile = {
entries: Record<string, EnrichedAgentHookEventPayload>
}
function equivalentInterruptAgentType(
actual: AgentType | undefined,
baseline: AgentType | undefined
): boolean {
const normalizedActual = actual === 'unknown' ? undefined : actual
const normalizedBaseline = baseline === 'unknown' ? undefined : baseline
return normalizedActual === normalizedBaseline
}
// Why: paneKey is `${tabId}:${leafUuid}` — validate the durable leaf suffix
// at write/hydrate time so legacy numeric rows fail closed.
export function isValidPaneKey(value: unknown): value is string {
@@ -180,6 +189,7 @@ function sanitizeHydratedEntry(
tabId: typeof tabId === 'string' ? tabId : undefined,
worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined,
connectionId,
hasExplicitPrompt: record.hasExplicitPrompt === true ? true : undefined,
payload,
receivedAt,
stateStartedAt
@@ -311,7 +321,7 @@ export class AgentHookServer {
// including same-millisecond prompt or agent identity changes.
if (
payload.state !== 'working' ||
agentType !== request.baselineAgentType ||
!equivalentInterruptAgentType(agentType, request.baselineAgentType) ||
payload.prompt !== request.baselinePrompt ||
existing.receivedAt !== request.baselineUpdatedAt ||
existing.stateStartedAt !== request.baselineStateStartedAt ||
@@ -391,7 +401,9 @@ export class AgentHookServer {
payload.payload.state === 'working' &&
previous.payload.agentType === payload.payload.agentType &&
previous.payload.prompt === payload.payload.prompt &&
Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS
(payload.isReplay === true ||
(payload.hasExplicitPrompt !== true &&
Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS))
) {
return previous
}
@@ -593,6 +605,8 @@ export class AgentHookServer {
worktreeId?: string
env?: string
version?: string
hasExplicitPrompt?: boolean
isReplay?: boolean
payload: unknown
},
connectionId: string
@@ -668,6 +682,8 @@ export class AgentHookServer {
tabId,
worktreeId,
connectionId: trimmedConnectionId,
hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined,
isReplay: envelope.isReplay === true ? true : undefined,
payload: normalizedPayload
}
this.applyNormalizedStatus(event)
+4
View File
@@ -228,6 +228,10 @@ export class DaemonPtyAdapter implements IPtyProvider {
})
}
hasPty(id: string): boolean {
return this.activeSessionIds.has(id)
}
write(id: string, data: string): void {
this.markSessionDirty(id)
this.client.notify('write', { sessionId: id, data })
+15
View File
@@ -29,6 +29,7 @@ function createAdapter(
title: label
}))
),
hasPty: vi.fn((id: string) => sessions.includes(id)),
write: vi.fn((id: string, data: string) => {
writes.push({ id, data })
}),
@@ -120,6 +121,20 @@ describe('DaemonPtyRouter', () => {
expect(current.spawn).toHaveBeenCalledWith({ sessionId: 'legacy-session', cols: 80, rows: 24 })
})
it('uses mapped adapter liveness instead of routing-cache presence for hasPty', async () => {
const current = createAdapter('current')
const legacy = createAdapter('legacy', ['legacy-session'])
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
await router.discoverLegacySessions()
expect(router.hasPty('legacy-session')).toBe(true)
await router.shutdown('legacy-session', { keepHistory: true })
expect(router.hasPty('legacy-session')).toBe(false)
expect(current.hasPty).not.toHaveBeenCalledWith('legacy-session')
})
it('merges startup reconciliation and updates route mappings', async () => {
const current = createAdapter('current', [], {
alive: ['current-alive'],
+8
View File
@@ -55,6 +55,14 @@ export class DaemonPtyRouter implements IPtyProvider {
await this.adapterFor(id).attach(id)
}
hasPty(id: string): boolean {
const routed = this.sessionAdapters.get(id)
if (routed) {
return routed.hasPty(id)
}
return this.current.hasPty(id) || this.legacy.some((adapter) => adapter.hasPty(id))
}
write(id: string, data: string): void {
this.adapterFor(id).write(id, data)
}
+38 -1
View File
@@ -9,6 +9,7 @@ import { makePaneKey } from '../../shared/stable-pane-id'
const dropStatusEntry = vi.fn()
const getStatusSnapshot = vi.fn()
const inferInterrupt = vi.fn()
const onHandlers = new Map<string, (event: unknown, ...args: unknown[]) => void>()
const handleHandlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>()
const removeHandler = vi.fn()
@@ -36,7 +37,8 @@ vi.mock('../agent-hooks/server', async () => {
...actual,
agentHookServer: {
dropStatusEntry,
getStatusSnapshot
getStatusSnapshot,
inferInterrupt
}
}
})
@@ -66,6 +68,7 @@ vi.mock('../hermes/hook-service', () => ({
beforeEach(() => {
dropStatusEntry.mockReset()
getStatusSnapshot.mockReset()
inferInterrupt.mockReset()
onHandlers.clear()
handleHandlers.clear()
removeHandler.mockReset()
@@ -98,6 +101,40 @@ describe('agentStatus:getSnapshot IPC', () => {
})
})
describe('agentStatus:inferInterrupt IPC', () => {
it('forwards valid inference requests to the hook server', async () => {
inferInterrupt.mockReturnValue(true)
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = handleHandlers.get('agentStatus:inferInterrupt')
expect(handler).toBeDefined()
const request = {
paneKey: PANE_KEY,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900,
baselinePrompt: 'long task',
baselineAgentType: 'codex',
intent: 'ctrl-c'
}
expect(handler!({}, request)).toBe(true)
expect(inferInterrupt).toHaveBeenCalledWith(request)
})
it('rejects malformed requests before the hook server boundary', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
registerAgentHookHandlers()
const handler = handleHandlers.get('agentStatus:inferInterrupt')
expect(handler).toBeDefined()
for (const value of [null, undefined, '', 123, true]) {
expect(handler!({}, value)).toBe(false)
}
expect(inferInterrupt).not.toHaveBeenCalled()
})
})
describe('agentStatus:drop IPC', () => {
it('forwards drop to dropStatusEntry', async () => {
const { registerAgentHookHandlers } = await import('./agent-hooks')
+20
View File
@@ -2354,6 +2354,26 @@ describe('registerPtyHandlers', () => {
}
})
it('acknowledges pty writes only for owned PTYs', async () => {
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
registerPtyHandlers(mainWindow as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24
})) as { id: string }
expect(handlers.get('pty:writeAccepted')!(null, { id: result.id, data: '\x03' })).toBe(true)
expect(mockProc.proc.write).toHaveBeenCalledWith('\x03')
expect(
handlers.get('pty:writeAccepted')!(null, {
id: 'missing-pty-for-write-ack',
data: '\x03'
})
).toBe(false)
expect(mockProc.proc.write).toHaveBeenCalledTimes(1)
})
it('upgrades legacy numeric pane keys when the spawn metadata proves the stable leaf', async () => {
registerPtyHandlers(mainWindow as never)
const leafId = '11111111-1111-4111-8111-111111111111'
+46 -3
View File
@@ -567,6 +567,7 @@ export function registerPtyHandlers(
ipcMain.removeHandler('pty:declarePendingPaneSerializer')
ipcMain.removeHandler('pty:settlePaneSerializer')
ipcMain.removeHandler('pty:clearPendingPaneSerializer')
ipcMain.removeHandler('pty:writeAccepted')
ipcMain.removeAllListeners('pty:write')
ipcMain.removeAllListeners('pty:ackColdRestore')
ipcMain.removeAllListeners('pty:serializeBuffer:response')
@@ -1537,7 +1538,7 @@ export function registerPtyHandlers(
}
)
ipcMain.on('pty:write', (_event, args: { id: string; data: string }) => {
const writePtyInput = (args: { id: string; data: string }): boolean => {
// Why: defense-in-depth for the mobile-presence lock. The renderer's
// xterm.onData guard already drops desktop keystrokes when mobile is
// driving, but a stale view between the main-side state flip and the
@@ -1545,9 +1546,51 @@ export function registerPtyHandlers(
// This server-side check catches it. See
// docs/mobile-presence-lock.md.
if (runtime?.getDriver(args.id).kind === 'mobile') {
return
return false
}
tryGetProviderForPty(args.id)?.write(args.id, args.data)
if (!ptyOwnership.has(args.id)) {
return false
}
const provider = tryGetProviderForPty(args.id)
if (!provider) {
return false
}
try {
provider.write(args.id, args.data)
return true
} catch {
return false
}
}
const writePtyInputAccepted = (args: { id: string; data: string }): boolean => {
if (runtime?.getDriver(args.id).kind === 'mobile') {
return false
}
// Why: the acknowledgement is used to infer Ctrl+C/Escape actually reached
// the local PTY. SSH providers are fire-and-forget relay notifications, so
// they cannot truthfully acknowledge until the relay protocol grows a write
// request/response.
if (ptyOwnership.get(args.id) !== null) {
return false
}
const provider = tryGetProviderForPty(args.id)
if (!provider?.hasPty?.(args.id)) {
return false
}
try {
provider.write(args.id, args.data)
return true
} catch {
return false
}
}
ipcMain.on('pty:write', (_event, args: { id: string; data: string }) => {
writePtyInput(args)
})
ipcMain.handle('pty:writeAccepted', (_event, args: { id: string; data: string }): boolean => {
return writePtyInputAccepted(args)
})
// Why: resize is fire-and-forget — the renderer doesn't need a reply.
+3
View File
@@ -440,6 +440,9 @@ export class LocalPtyProvider implements IPtyProvider {
// Local PTYs are always attached -- no-op. Remote providers use this to resubscribe.
async attach(_id: string): Promise<void> {}
hasPty(id: string): boolean {
return ptyProcesses.has(id)
}
write(id: string, data: string): void {
ptyProcesses.get(id)?.write(data)
}
+1
View File
@@ -83,6 +83,7 @@ export type PtySpawnResult = {
export type IPtyProvider = {
spawn(opts: PtySpawnOptions): Promise<PtySpawnResult>
attach(id: string): Promise<void>
hasPty?: (id: string) => boolean
write(id: string, data: string): void
resize(id: string, cols: number, rows: number): void
shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise<void>
@@ -1,3 +1,6 @@
/* eslint-disable max-lines -- Why: this integration spec keeps the SSH relay,
agent-hook server, and replay/interrupt ordering fixtures together so regressions
cover the full mux-to-main path. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
@@ -324,4 +327,120 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => {
expect(events).toHaveLength(0)
expect(agentHookServer.getStatusSnapshot()).toEqual([])
})
it('preserves explicit-prompt metadata from remote hook notifications', async () => {
relay = createFakeRelay()
vi.mocked(deployAndLaunchRelay).mockResolvedValue({
transport: relay.transport,
platform: 'linux-x64'
})
session = createSession('conn-explicit-prompt')
await session.establish({} as SshConnection)
relay.notifyAgentHook(
makeEnvelope({
hasExplicitPrompt: true,
payload: {
state: 'working',
prompt: 'retry same prompt',
agentType: 'opencode',
lastAssistantMessage: 'partial answer'
}
})
)
await vi.waitFor(() => expect(agentHookServer.getStatusSnapshot()).toHaveLength(1), {
timeout: 1500
})
const first = agentHookServer.getStatusSnapshot()[0]
expect(
agentHookServer.inferInterrupt({
paneKey: first.paneKey,
baselineUpdatedAt: first.receivedAt,
baselineStateStartedAt: first.stateStartedAt,
baselinePrompt: 'retry same prompt',
baselineAgentType: 'opencode',
intent: 'ctrl-c'
})
).toBe(true)
relay.notifyAgentHook(
makeEnvelope({
hasExplicitPrompt: true,
payload: {
state: 'working',
prompt: 'retry same prompt',
agentType: 'opencode',
lastAssistantMessage: 'partial answer'
}
})
)
await vi.waitFor(() =>
expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({
state: 'working',
prompt: 'retry same prompt',
agentType: 'opencode',
lastAssistantMessage: 'partial answer'
})
)
})
it('preserves replay metadata from remote hook notifications', async () => {
relay = createFakeRelay()
vi.mocked(deployAndLaunchRelay).mockResolvedValue({
transport: relay.transport,
platform: 'linux-x64'
})
session = createSession('conn-replay-marker')
await session.establish({} as SshConnection)
relay.notifyAgentHook(
makeEnvelope({
hasExplicitPrompt: true,
payload: {
state: 'working',
prompt: 'replayed prompt',
agentType: 'opencode'
}
})
)
await vi.waitFor(() => expect(agentHookServer.getStatusSnapshot()).toHaveLength(1), {
timeout: 1500
})
const first = agentHookServer.getStatusSnapshot()[0]
expect(
agentHookServer.inferInterrupt({
paneKey: first.paneKey,
baselineUpdatedAt: first.receivedAt,
baselineStateStartedAt: first.stateStartedAt,
baselinePrompt: 'replayed prompt',
baselineAgentType: 'opencode',
intent: 'ctrl-c'
})
).toBe(true)
relay.notifyAgentHook(
makeEnvelope({
hasExplicitPrompt: true,
isReplay: true,
payload: {
state: 'working',
prompt: 'replayed prompt',
agentType: 'opencode'
}
})
)
await new Promise((resolve) => setImmediate(resolve))
expect(agentHookServer.getStatusSnapshot()[0]).toMatchObject({
state: 'done',
prompt: 'replayed prompt',
agentType: 'opencode',
interrupted: true
})
})
})
+4
View File
@@ -583,6 +583,8 @@ export class SshRelaySession {
worktreeId?: unknown
env?: unknown
version?: unknown
hasExplicitPrompt?: unknown
isReplay?: unknown
payload?: unknown
}
if (typeof envelope.paneKey !== 'string') {
@@ -600,6 +602,8 @@ export class SshRelaySession {
worktreeId: typeof envelope.worktreeId === 'string' ? envelope.worktreeId : undefined,
env: typeof envelope.env === 'string' ? envelope.env : undefined,
version: typeof envelope.version === 'string' ? envelope.version : undefined,
hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined,
isReplay: envelope.isReplay === true ? true : undefined,
payload: envelope.payload
},
this.targetId
+1
View File
@@ -683,6 +683,7 @@ export type PreloadApi = {
coldRestore?: { scrollback: string; cwd: string }
}>
write: (id: string, data: string) => void
writeAccepted: (id: string, data: string) => Promise<boolean>
resize: (id: string, cols: number, rows: number) => void
reportGeometry: (id: string, cols: number, rows: number) => void
signal: (id: string, signal: string) => void
+2
View File
@@ -585,6 +585,8 @@ const api = {
write: (id: string, data: string): void => {
ipcRenderer.send('pty:write', { id, data })
},
writeAccepted: (id: string, data: string): Promise<boolean> =>
ipcRenderer.invoke('pty:writeAccepted', { id, data }),
resize: (id: string, cols: number, rows: number): void => {
ipcRenderer.send('pty:resize', { id, cols, rows })
+1
View File
@@ -117,6 +117,7 @@ describe('RelayAgentHookServer', () => {
expect(forward.mock.calls[0][0].source).toBe('claude')
expect(forward.mock.calls[0][0].env).toBe('remote')
expect(forward.mock.calls[0][0].version).toBe('1')
expect(forward.mock.calls[0][0].isReplay).toBe(true)
} finally {
server.stop()
}
+5 -2
View File
@@ -176,7 +176,7 @@ export class RelayAgentHookServer {
if (!meta) {
continue
}
this.forwardEvent(event, meta.source, meta.env, meta.version)
this.forwardEvent(event, meta.source, meta.env, meta.version, { isReplay: true })
count++
}
return count
@@ -267,7 +267,8 @@ export class RelayAgentHookServer {
event: AgentHookEventPayload,
source: AgentHookSource,
env?: string,
version?: string
version?: string,
options: { isReplay?: boolean } = {}
): void {
const envelope: AgentHookRelayEnvelope = {
source,
@@ -275,6 +276,8 @@ export class RelayAgentHookServer {
tabId: event.tabId,
worktreeId: event.worktreeId,
connectionId: null,
hasExplicitPrompt: event.hasExplicitPrompt,
isReplay: options.isReplay === true ? true : undefined,
env,
version,
payload: event.payload
@@ -22,6 +22,18 @@ function makeEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry
}
}
function keyEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
return {
key: '',
ctrlKey: false,
metaKey: false,
altKey: false,
shiftKey: false,
repeat: false,
...overrides
} as KeyboardEvent
}
describe('agent interrupt inference', () => {
afterEach(() => {
vi.useRealTimers()
@@ -229,7 +241,26 @@ describe('agent interrupt inference', () => {
tracker.dispose()
})
it('cancels when the status disappears during the settle window', () => {
it('cancels when a normal done hook arrives during the settle window', () => {
vi.useFakeTimers()
const inferInterrupt = vi.fn()
let entry: AgentStatusEntry | undefined = makeEntry()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => entry,
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('ctrl-c')
entry = makeEntry({ state: 'done', updatedAt: 1_050, stateStartedAt: 1_050 })
vi.advanceTimersByTime(500)
expect(inferInterrupt).not.toHaveBeenCalled()
tracker.dispose()
})
it('emits the captured baseline when the renderer status disappears during the settle window', () => {
vi.useFakeTimers()
const inferInterrupt = vi.fn()
let entry: AgentStatusEntry | undefined = makeEntry()
@@ -244,80 +275,46 @@ describe('agent interrupt inference', () => {
entry = undefined
vi.advanceTimersByTime(500)
expect(inferInterrupt).not.toHaveBeenCalled()
expect(inferInterrupt).toHaveBeenCalledWith({
paneKey: PANE_KEY,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900,
baselinePrompt: 'write tests',
baselineAgentType: 'codex',
intent: 'plain-escape'
})
tracker.dispose()
})
it('dispose cancels a pending inference timer', () => {
vi.useFakeTimers()
const inferInterrupt = vi.fn()
const tracker = createAgentInterruptInference({
paneKey: PANE_KEY,
getStatusEntry: () => makeEntry(),
inferInterrupt,
now: () => 1_100
})
tracker.observeInputIntent('ctrl-c')
tracker.dispose()
vi.advanceTimersByTime(500)
expect(inferInterrupt).not.toHaveBeenCalled()
})
it('requires exact plain Escape and Ctrl+C key events', () => {
expect(
isPlainEscapeKeyEvent({
key: 'Escape',
ctrlKey: false,
metaKey: false,
altKey: false,
shiftKey: false,
repeat: false
} as KeyboardEvent)
).toBe(true)
expect(
isPlainEscapeKeyEvent({
key: 'Escape',
ctrlKey: false,
metaKey: false,
altKey: true,
shiftKey: false,
repeat: false
} as KeyboardEvent)
).toBe(false)
expect(
isPlainEscapeKeyEvent({
key: 'c',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
repeat: false
} as KeyboardEvent)
).toBe(false)
expect(
isCtrlCKeyEvent({
key: 'c',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
repeat: false
} as KeyboardEvent)
).toBe(true)
expect(
isCtrlCKeyEvent({
key: 'C',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
repeat: false
} as KeyboardEvent)
).toBe(true)
expect(
isCtrlCKeyEvent({
key: 'c',
ctrlKey: true,
metaKey: true,
altKey: false,
shiftKey: false,
repeat: false
} as KeyboardEvent)
).toBe(false)
expect(
isCtrlCKeyEvent({
key: 'c',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
repeat: true
} as KeyboardEvent)
).toBe(false)
expect(isPlainEscapeKeyEvent(keyEvent({ key: 'Escape' }))).toBe(true)
expect(isCtrlCKeyEvent(keyEvent({ key: 'c', ctrlKey: true }))).toBe(true)
expect(isCtrlCKeyEvent(keyEvent({ key: 'C', ctrlKey: true }))).toBe(true)
for (const event of [
keyEvent({ key: 'Escape', altKey: true }),
keyEvent({ key: 'Escape', shiftKey: true }),
keyEvent({ key: 'Escape', repeat: true }),
keyEvent({ key: 'c', ctrlKey: true, metaKey: true }),
keyEvent({ key: 'c', ctrlKey: true, repeat: true })
]) {
expect(isPlainEscapeKeyEvent(event) || isCtrlCKeyEvent(event)).toBe(false)
}
})
})
@@ -11,13 +11,14 @@ import { isExplicitAgentStatusFresh } from '@/lib/agent-status'
export type AgentInterruptInference = {
observeInputIntent(intent: AgentInterruptInputIntent): void
flushPending(): boolean | Promise<boolean>
dispose(): void
}
type AgentInterruptInferenceDeps = {
paneKey: string
getStatusEntry: () => AgentStatusEntry | undefined
inferInterrupt: (request: AgentInterruptInferenceRequest) => void
inferInterrupt: (request: AgentInterruptInferenceRequest) => boolean | Promise<boolean> | void
now?: () => number
setTimer?: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void
@@ -121,28 +122,31 @@ export function createAgentInterruptInference({
}
}
const flushPending = (): void => {
const flushPending = (): boolean | Promise<boolean> => {
const baseline = pendingBaseline
pendingTimer = null
pendingBaseline = null
if (!baseline) {
return
return false
}
const entry = getStatusEntry()
if (
!entry ||
entry.state !== 'working' ||
entry.agentType !== baseline.agentType ||
entry.prompt !== baseline.prompt ||
entry.updatedAt !== baseline.updatedAt ||
entry.stateStartedAt !== baseline.stateStartedAt ||
!isExplicitAgentStatusFresh(entry, now(), AGENT_STATUS_STALE_AFTER_MS)
entry &&
(entry.state !== 'working' ||
entry.agentType !== baseline.agentType ||
entry.prompt !== baseline.prompt ||
entry.updatedAt !== baseline.updatedAt ||
entry.stateStartedAt !== baseline.stateStartedAt ||
!isExplicitAgentStatusFresh(entry, now(), AGENT_STATUS_STALE_AFTER_MS))
) {
return
return false
}
if (!entry && now() - baseline.updatedAt > AGENT_STATUS_STALE_AFTER_MS) {
return false
}
inferInterrupt({
const result = inferInterrupt({
paneKey,
baselineUpdatedAt: baseline.updatedAt,
baselineStateStartedAt: baseline.stateStartedAt,
@@ -151,6 +155,11 @@ export function createAgentInterruptInference({
intent: baseline.intent,
...(baseline.inputCount !== undefined ? { inputCount: baseline.inputCount } : {})
})
return result ?? true
}
const flushPendingFromTimer = (): void => {
void flushPending()
}
return {
@@ -181,8 +190,9 @@ export function createAgentInterruptInference({
clearPendingTimer()
}
pendingBaseline = baseline
pendingTimer = setTimer(flushPending, AGENT_INTERRUPT_SETTLE_MS)
pendingTimer = setTimer(flushPendingFromTimer, AGENT_INTERRUPT_SETTLE_MS)
},
flushPending,
dispose() {
clearPending()
}
@@ -80,6 +80,7 @@ type MockTransport = {
) => unknown
}
sendInput: ReturnType<typeof vi.fn>
sendInputAccepted?: ReturnType<typeof vi.fn>
resize: ReturnType<typeof vi.fn>
getPtyId: ReturnType<typeof vi.fn>
}
@@ -180,7 +181,7 @@ vi.mock('./remote-runtime-pty-transport', () => ({
function createMockTransport(initialPtyId: string | null = null): MockTransport {
let ptyId = initialPtyId
return {
const transport = {
attach: vi.fn(({ existingPtyId }: { existingPtyId: string }) => {
ptyId = existingPtyId
}),
@@ -195,6 +196,9 @@ function createMockTransport(initialPtyId: string | null = null): MockTransport
resize: vi.fn(() => true),
getPtyId: vi.fn(() => ptyId)
} as MockTransport
const sendInput = transport.sendInput as unknown as (data: string) => boolean
transport.sendInputAccepted = vi.fn(async (data: string) => sendInput(data))
return transport
}
function createPane(paneId: number) {
@@ -210,6 +214,7 @@ function createPane(paneId: number) {
onData: vi.fn(() => ({ dispose: vi.fn() })),
onResize: vi.fn(() => ({ dispose: vi.fn() })),
onTitleChange: vi.fn(() => ({ dispose: vi.fn() })),
hasSelection: vi.fn(() => false),
parser: {
registerOscHandler: vi.fn(() => ({ dispose: vi.fn() }))
}
@@ -267,6 +272,54 @@ function createDeps(overrides: Record<string, unknown> = {}) {
}
}
function createKeyboardEventTarget() {
const handlers = new Set<(event: KeyboardEvent) => void>()
return {
handlers,
target: {
addEventListener: vi.fn(
(
type: string,
handler: EventListenerOrEventListenerObject,
_options?: AddEventListenerOptions | boolean
) => {
if (type === 'keydown' && typeof handler === 'function') {
handlers.add(handler as (event: KeyboardEvent) => void)
}
}
),
removeEventListener: vi.fn(
(
type: string,
handler: EventListenerOrEventListenerObject,
_options?: EventListenerOptions | boolean
) => {
if (type === 'keydown' && typeof handler === 'function') {
handlers.delete(handler as (event: KeyboardEvent) => void)
}
}
)
},
dispatch(event: KeyboardEvent) {
for (const handler of handlers) {
handler(event)
}
}
}
}
function keyEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
return {
key: '',
ctrlKey: false,
metaKey: false,
altKey: false,
shiftKey: false,
repeat: false,
...overrides
} as KeyboardEvent
}
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolveDeferred!: (value: T) => void
const promise = new Promise<T>((resolve) => {
@@ -329,6 +382,9 @@ describe('connectPanePty', () => {
notifications: {
dispatch: vi.fn().mockResolvedValue({ delivered: true }),
playSound: vi.fn().mockResolvedValue({ played: true })
},
agentStatus: {
inferInterrupt: vi.fn().mockResolvedValue(false)
}
}
}
@@ -414,6 +470,186 @@ describe('connectPanePty', () => {
)
})
it('infers interrupts only from the focused terminal key target', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
vi.useFakeTimers()
vi.setSystemTime(1_100)
const paneKey = makePaneKey('tab-1', LEAF_1)
mockStoreState.agentStatusByPaneKey[paneKey] = {
state: 'working',
prompt: 'stop this task',
updatedAt: 1_000,
stateStartedAt: 900,
agentType: 'codex',
paneKey,
terminalTitle: 'Codex',
stateHistory: []
}
const terminalTarget = createKeyboardEventTarget()
const unrelatedTarget = createKeyboardEventTarget()
;(
globalThis.window as unknown as { addEventListener?: ReturnType<typeof vi.fn> }
).addEventListener = vi.fn()
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)
if (!onDataHandler) {
throw new Error('expected onData handler to be registered')
}
unrelatedTarget.dispatch({
key: 'c',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
repeat: false
} as KeyboardEvent)
vi.advanceTimersByTime(500)
expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled()
expect(globalThis.window.addEventListener).not.toHaveBeenCalled()
terminalTarget.dispatch({
key: 'c',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
repeat: false
} as KeyboardEvent)
;(onDataHandler as unknown as (data: string) => void)('\x03')
await flushAsyncTicks()
vi.advanceTimersByTime(500)
expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({
paneKey,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900,
baselinePrompt: 'stop this task',
baselineAgentType: 'codex',
intent: 'ctrl-c'
})
})
it('ignores repeated and modified terminal interrupt keydowns', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
vi.useFakeTimers()
vi.setSystemTime(1_100)
const paneKey = makePaneKey('tab-1', LEAF_1)
mockStoreState.agentStatusByPaneKey[paneKey] = {
state: 'working',
prompt: 'keep running',
updatedAt: 1_000,
stateStartedAt: 900,
agentType: 'codex',
paneKey,
terminalTitle: 'Codex',
stateHistory: []
}
const terminalTarget = createKeyboardEventTarget()
const pane = createPane(1)
;(pane.terminal as { element?: unknown }).element = terminalTarget.target
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
for (const event of [
{
key: 'Escape',
ctrlKey: false,
metaKey: false,
altKey: false,
shiftKey: false,
repeat: true
},
{
key: 'Escape',
ctrlKey: false,
metaKey: false,
altKey: true,
shiftKey: false,
repeat: false
},
{
key: 'c',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: true,
repeat: false
}
]) {
terminalTarget.dispatch(event as KeyboardEvent)
}
vi.advanceTimersByTime(500)
expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled()
})
it('does not infer Ctrl+C when terminal selection turns the chord into copy', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
vi.useFakeTimers()
vi.setSystemTime(1_100)
const paneKey = makePaneKey('tab-1', LEAF_1)
mockStoreState.agentStatusByPaneKey[paneKey] = {
state: 'working',
prompt: 'copy selection',
updatedAt: 1_000,
stateStartedAt: 900,
agentType: 'codex',
paneKey,
terminalTitle: 'Codex',
stateHistory: []
}
const terminalTarget = createKeyboardEventTarget()
const pane = createPane(1)
;(pane.terminal as { element?: unknown; hasSelection: ReturnType<typeof vi.fn> }).element =
terminalTarget.target
;(pane.terminal as { hasSelection: ReturnType<typeof vi.fn> }).hasSelection.mockReturnValue(
true
)
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true }))
vi.advanceTimersByTime(500)
expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled()
})
it('removes the terminal key listener on dispose before a remount adds another', async () => {
const { connectPanePty } = await import('./pty-connection')
const firstTransport = createMockTransport()
const secondTransport = createMockTransport()
transportFactoryQueue.push(firstTransport, secondTransport)
const terminalTarget = createKeyboardEventTarget()
const pane = createPane(1)
;(pane.terminal as { element?: unknown }).element = terminalTarget.target
const firstConnection = connectPanePty(
pane as never,
createManager(1) as never,
createDeps() as never
)
expect(terminalTarget.handlers.size).toBe(1)
firstConnection.dispose()
expect(terminalTarget.handlers.size).toBe(0)
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
expect(terminalTarget.handlers.size).toBe(1)
expect(terminalTarget.target.addEventListener).toHaveBeenCalledTimes(2)
expect(terminalTarget.target.removeEventListener).toHaveBeenCalledTimes(1)
})
it('does not reuse a sibling split pane pending spawn after remount', async () => {
const { connectPanePty } = await import('./pty-connection')
@@ -508,6 +744,8 @@ describe('connectPanePty', () => {
it('blocks input to stale Codex panes until they restart', async () => {
const { connectPanePty } = await import('./pty-connection')
vi.useFakeTimers()
vi.setSystemTime(1_100)
const transport = createMockTransport('pty-codex-stale')
transportFactoryQueue.push(transport)
mockStoreState = {
@@ -520,10 +758,23 @@ describe('connectPanePty', () => {
},
codexRestartNoticeByPtyId: {
'pty-codex-stale': { previousAccountLabel: 'A', nextAccountLabel: 'B' }
},
agentStatusByPaneKey: {
[makePaneKey('tab-1', LEAF_1)]: {
paneKey: makePaneKey('tab-1', LEAF_1),
state: 'working',
prompt: 'stale input',
updatedAt: 1_000,
stateStartedAt: 900,
agentType: 'codex',
stateHistory: []
}
}
}
const pane = createPane(1)
const terminalTarget = createKeyboardEventTarget()
;(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
@@ -539,9 +790,150 @@ describe('connectPanePty', () => {
throw new Error('expected onData handler to be registered')
}
const sendTerminalInput = onDataHandler as (data: string) => void
sendTerminalInput('hello')
terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true }))
sendTerminalInput('\x03')
vi.advanceTimersByTime(500)
expect(transport.sendInput).not.toHaveBeenCalled()
expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled()
})
it('does not infer interrupts when mobile presence lock blocks terminal input', async () => {
const { connectPanePty } = await import('./pty-connection')
const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state')
vi.useFakeTimers()
vi.setSystemTime(1_100)
const ptyId = 'pty-mobile-locked'
setDriverForPty(ptyId, { kind: 'mobile', clientId: 'phone-1' })
try {
const transport = createMockTransport(ptyId)
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId }] },
ptyIdsByTabId: { 'tab-1': [ptyId] },
agentStatusByPaneKey: {
[paneKey]: {
paneKey,
state: 'working',
prompt: 'locked input',
updatedAt: 1_000,
stateStartedAt: 900,
agentType: 'codex',
stateHistory: []
}
}
}
const pane = createPane(1)
const terminalTarget = createKeyboardEventTarget()
;(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)
if (!onDataHandler) {
throw new Error('expected onData handler to be registered')
}
terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true }))
;(onDataHandler as unknown as (data: string) => void)('\x03')
vi.advanceTimersByTime(500)
expect(transport.sendInput).not.toHaveBeenCalled()
expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled()
} finally {
setDriverForPty(ptyId, { kind: 'idle' })
}
})
it('does not infer interrupts when the transport rejects terminal input', async () => {
const { connectPanePty } = await import('./pty-connection')
vi.useFakeTimers()
vi.setSystemTime(1_100)
const transport = createMockTransport('pty-disconnected')
transport.sendInput.mockReturnValue(false)
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
mockStoreState.agentStatusByPaneKey[paneKey] = {
paneKey,
state: 'working',
prompt: 'disconnected input',
updatedAt: 1_000,
stateStartedAt: 900,
agentType: 'codex',
stateHistory: []
}
const pane = createPane(1)
const terminalTarget = createKeyboardEventTarget()
;(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)
if (!onDataHandler) {
throw new Error('expected onData handler to be registered')
}
terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true }))
;(onDataHandler as unknown as (data: string) => void)('\x03')
vi.advanceTimersByTime(500)
expect(transport.sendInput).toHaveBeenCalledWith('\x03')
expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled()
})
it('does not infer interrupts when the main process rejects acknowledged input', async () => {
const { connectPanePty } = await import('./pty-connection')
vi.useFakeTimers()
vi.setSystemTime(1_100)
const transport = createMockTransport('pty-mobile-race')
transport.sendInputAccepted = vi.fn().mockResolvedValue(false)
transportFactoryQueue.push(transport)
const paneKey = makePaneKey('tab-1', LEAF_1)
mockStoreState.agentStatusByPaneKey[paneKey] = {
paneKey,
state: 'working',
prompt: 'mobile race input',
updatedAt: 1_000,
stateStartedAt: 900,
agentType: 'codex',
stateHistory: []
}
const pane = createPane(1)
const terminalTarget = createKeyboardEventTarget()
;(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)
if (!onDataHandler) {
throw new Error('expected onData handler to be registered')
}
terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true }))
;(onDataHandler as unknown as (data: string) => void)('\x03')
await flushAsyncTicks()
vi.advanceTimersByTime(500)
expect(transport.sendInputAccepted).toHaveBeenCalledWith('\x03')
expect(transport.sendInput).not.toHaveBeenCalled()
expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled()
})
it('blocks input when tab-level ptyId is stale even if panePtyId is null', async () => {
@@ -670,6 +1062,136 @@ describe('connectPanePty', () => {
expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled()
})
it('flushes pending interrupt inference before OSC 133 can drop the status row', async () => {
const { connectPanePty } = await import('./pty-connection')
vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(true)
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 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')
}
terminalTarget.dispatch(
keyEvent({
key: 'c',
ctrlKey: true
})
)
;(onDataHandler as unknown as (data: string) => void)('\x03')
capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ')
expect(window.api.agentStatus.inferInterrupt).not.toHaveBeenCalled()
expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled()
writeAccepted.resolve(true)
await flushAsyncTicks()
expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalledWith({
paneKey,
baselineUpdatedAt: 1_000,
baselineStateStartedAt: 900,
baselinePrompt: 'stop quickly',
baselineAgentType: 'codex',
intent: 'ctrl-c'
})
expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled()
})
it('drops the command-finished status when pending interrupt inference is rejected', async () => {
const { connectPanePty } = await import('./pty-connection')
vi.mocked(window.api.agentStatus.inferInterrupt).mockResolvedValue(false)
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
const transport = createMockTransport()
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedDataCallback.current = callbacks.onData ?? null
return { id: 'tab-pty' }
})
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 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()
if (!onDataHandler) {
throw new Error('expected onData handler to be registered')
}
terminalTarget.dispatch(keyEvent({ key: 'c', ctrlKey: true }))
;(onDataHandler as unknown as (data: string) => void)('\x03')
capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ')
await flushAsyncTicks()
expect(window.api.agentStatus.inferInterrupt).toHaveBeenCalled()
expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey)
})
it('reattaches a remounted split pane to its restored leaf PTY instead of the tab-level PTY', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
@@ -34,6 +34,7 @@ import {
isCtrlCKeyEvent,
isPlainEscapeKeyEvent
} from './agent-interrupt-inference'
import type { AgentInterruptInputIntent } from '../../../../shared/agent-interrupt-intent'
const pendingSpawnByPaneKey = new Map<string, Promise<string | null>>()
const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED'
@@ -193,35 +194,112 @@ export function connectPanePty(
// Use the stable layout leaf UUID, not the renderer-local numeric pane id.
const cacheKey = makePaneKey(deps.tabId, pane.leafId)
const pendingSpawnKey = cacheKey
const commandLifecycle = createTerminalCommandLifecycle({
onCommandFinished: () => {
const state = useAppStore.getState()
const entry = state.agentStatusByPaneKey[cacheKey]
// Why: OSC 133 D marks the foreground shell command exiting. Remove the
// row without retaining a done snapshot; this section represents a live
// agent process, and the shell prompt means that process is gone.
if (entry) {
state.dropAgentStatus(cacheKey)
}
}
})
commandLifecycle.attachXtermConsumer(pane.terminal)
const interruptInference = createAgentInterruptInference({
paneKey: cacheKey,
getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[cacheKey],
inferInterrupt: (request) => {
void window.api.agentStatus.inferInterrupt(request).catch((err) => {
return window.api.agentStatus.inferInterrupt(request).catch((err) => {
console.warn('[agent-interrupt] inferInterrupt failed:', err)
return false
})
}
})
const dropCommandFinishedStatusIfUnchanged = (entry: AgentStatusEntry | undefined): void => {
if (!entry) {
return
}
const state = useAppStore.getState()
const current = state.agentStatusByPaneKey[cacheKey]
if (
!current ||
current.state !== entry.state ||
current.prompt !== entry.prompt ||
current.updatedAt !== entry.updatedAt ||
current.stateStartedAt !== entry.stateStartedAt ||
current.agentType !== entry.agentType
) {
return
}
state.dropAgentStatus(cacheKey)
}
let pendingTerminalInputIntent: AgentInterruptInputIntent | null = null
let clearPendingTerminalInputIntentTimer: ReturnType<typeof setTimeout> | null = null
const clearPendingTerminalInputIntent = (): void => {
pendingTerminalInputIntent = null
if (clearPendingTerminalInputIntentTimer !== null) {
clearTimeout(clearPendingTerminalInputIntentTimer)
clearPendingTerminalInputIntentTimer = null
}
}
const setPendingTerminalInputIntent = (intent: AgentInterruptInputIntent): void => {
clearPendingTerminalInputIntent()
pendingTerminalInputIntent = intent
clearPendingTerminalInputIntentTimer = setTimeout(() => {
clearPendingTerminalInputIntent()
}, 0)
}
const inputMatchesIntent = (intent: AgentInterruptInputIntent, data: string): boolean => {
return (
(intent === 'plain-escape' && data === '\x1b') || (intent === 'ctrl-c' && data === '\x03')
)
}
const observeSentTerminalInputIntent = (
data: string,
intent = pendingTerminalInputIntent
): void => {
if (intent && inputMatchesIntent(intent, data)) {
interruptInference.observeInputIntent(intent)
}
}
let pendingTerminalInputWrite: Promise<void> | null = null
const setPendingTerminalInputWrite = (promise: Promise<void>): void => {
pendingTerminalInputWrite = promise
void promise.finally(() => {
if (pendingTerminalInputWrite === promise) {
pendingTerminalInputWrite = null
}
})
}
const flushPendingInterruptInference = (): boolean | Promise<boolean> => {
const pendingWrite = pendingTerminalInputWrite
if (!pendingWrite) {
return interruptInference.flushPending()
}
return pendingWrite.then(() => interruptInference.flushPending())
}
const commandLifecycle = createTerminalCommandLifecycle({
onCommandFinished: () => {
const state = useAppStore.getState()
const entry = state.agentStatusByPaneKey[cacheKey]
const inferenceResult = flushPendingInterruptInference()
if (inferenceResult === true) {
return
}
if (inferenceResult instanceof Promise) {
void inferenceResult.then((applied) => {
if (!applied) {
dropCommandFinishedStatusIfUnchanged(entry)
}
})
return
}
// Why: OSC 133 D marks the foreground shell command exiting. Remove the
// row without retaining a done snapshot; this section represents a live
// agent process, and the shell prompt means that process is gone.
dropCommandFinishedStatusIfUnchanged(entry)
}
})
commandLifecycle.attachXtermConsumer(pane.terminal)
const onTerminalKeyDown = (event: KeyboardEvent): void => {
if (isPlainEscapeKeyEvent(event)) {
interruptInference.observeInputIntent('plain-escape')
setPendingTerminalInputIntent('plain-escape')
return
}
if (isCtrlCKeyEvent(event)) {
interruptInference.observeInputIntent('ctrl-c')
if (!navigator.userAgent.includes('Mac') && pane.terminal.hasSelection()) {
return
}
setPendingTerminalInputIntent('ctrl-c')
}
}
// Why: infer only from focused xterm key events. Raw PTY bytes cannot
@@ -584,6 +662,7 @@ export function connectPanePty(
// the block still holds during reconnect races before the live transport has
// updated its local PTY binding.
if (isCodexPaneStale({ tabId: deps.tabId, panePtyId: currentPtyId })) {
clearPendingTerminalInputIntent()
return
}
// Why: presence-lock input drop. While mobile is the driver for this
@@ -594,6 +673,7 @@ export function connectPanePty(
// The pty:write IPC has a defense-in-depth twin. See
// docs/mobile-presence-lock.md.
if (currentPtyId && isPtyLocked(currentPtyId)) {
clearPendingTerminalInputIntent()
return
}
// Why: a real keystroke into the terminal is the unambiguous "user is
@@ -602,7 +682,32 @@ export function connectPanePty(
// auto-replies never count as interaction.
deps.clearTerminalTabUnread(deps.tabId)
deps.clearWorktreeUnread(deps.worktreeId)
transport.sendInput(data)
const intent = pendingTerminalInputIntent
if (intent && transport.sendInputAccepted) {
clearPendingTerminalInputIntent()
const writePromise = transport
.sendInputAccepted(data)
.then((accepted) => {
if (accepted) {
observeSentTerminalInputIntent(data, intent)
}
})
.catch((err) => {
console.warn('[agent-interrupt] acknowledged terminal input failed:', err)
})
setPendingTerminalInputWrite(writePromise)
return
}
if (intent) {
transport.sendInput(data)
clearPendingTerminalInputIntent()
return
}
if (transport.sendInput(data)) {
observeSentTerminalInputIntent(data)
} else {
clearPendingTerminalInputIntent()
}
})
const onResizeDisposable = pane.terminal.onResize(({ cols, rows }) => {
@@ -1454,6 +1559,8 @@ export function connectPanePty(
if (terminalKeyTargetSupportsEvents) {
terminalKeyTarget.removeEventListener('keydown', onTerminalKeyDown, { capture: true })
}
clearPendingTerminalInputIntent()
pendingTerminalInputWrite = null
interruptInference.dispose()
// Why: actively resolve any in-flight passphrase-gate waits so their
// zustand subscribers + async IIFEs don't hang for the rest of the
@@ -264,6 +264,7 @@ export type PtyTransport = {
}) => void
disconnect: () => void
sendInput: (data: string) => boolean
sendInputAccepted?: (data: string) => Promise<boolean>
resize: (
cols: number,
rows: number,
@@ -28,6 +28,7 @@ describe('createIpcPtyTransport', () => {
...originalWindow?.api?.pty,
spawn: vi.fn().mockResolvedValue({ id: 'pty-1' }),
write: vi.fn(),
writeAccepted: vi.fn().mockResolvedValue(true),
resize: vi.fn(),
kill: vi.fn(),
onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => {
@@ -67,6 +68,19 @@ describe('createIpcPtyTransport', () => {
transport.disconnect()
})
it('uses acknowledged writes only for local IPC PTYs', async () => {
const { createIpcPtyTransport } = await import('./pty-transport')
const localTransport = createIpcPtyTransport({})
await localTransport.connect({ url: '', callbacks: {} })
await expect(localTransport.sendInputAccepted?.('\x03')).resolves.toBe(true)
expect(window.api.pty.writeAccepted).toHaveBeenCalledWith('pty-1', '\x03')
const sshTransport = createIpcPtyTransport({ connectionId: 'ssh-1' })
await sshTransport.connect({ url: '', callbacks: {} })
expect(sshTransport.sendInputAccepted).toBeUndefined()
})
it('suppresses attention side effects when replaying eager-buffered data during attach', async () => {
// Why: eager PTY buffers capture output produced before the pane mounted —
// typically catch-up bytes from a previous app session. A BEL or
@@ -520,6 +520,17 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
return true
},
...(connectionId
? {}
: {
async sendInputAccepted(data: string): Promise<boolean> {
if (!connected || !ptyId) {
return false
}
return window.api.pty.writeAccepted(ptyId, data)
}
}),
resize(cols: number, rows: number): boolean {
if (!connected || !ptyId) {
return false
@@ -1,5 +1,6 @@
export type RemoteRuntimePtyBatcher = {
push: (data: string) => void
takePending: () => string
flush: () => void
clear: () => void
}
@@ -25,14 +26,19 @@ export function createRemoteRuntimePtyTextBatcher(
}
const flush = (): void => {
const text = pending
pending = ''
clear()
const text = takePending()
if (text) {
onFlush(text)
}
}
const takePending = (): string => {
const text = pending
pending = ''
clear()
return text
}
return {
push(data: string): void {
pending += data
@@ -40,6 +46,7 @@ export function createRemoteRuntimePtyTextBatcher(
timer = setTimeout(flush, delayMs)
}
},
takePending,
flush,
clear
}
@@ -661,6 +661,111 @@ describe('createRemoteRuntimePtyTransport', () => {
}
})
it('returns runtime acceptance for acknowledged terminal input', async () => {
runtimeCall.mockImplementation((args) => {
if (args.method === 'terminal.create') {
return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
}
if (args.method === 'terminal.send') {
return Promise.resolve({
ok: true,
result: { send: { handle: 'terminal-1', accepted: true, bytesWritten: 1 } }
})
}
return Promise.resolve({ ok: true, result: {} })
})
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1'
})
await transport.connect({ url: '', callbacks: {} })
await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(true)
expect(runtimeCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'terminal.send',
params: {
terminal: 'terminal-1',
text: '\x03',
client: { id: 'desktop:tab-1:pane:1', type: 'desktop' }
},
timeoutMs: 15_000
})
})
it('preserves queued remote input order before acknowledged terminal input', async () => {
vi.useFakeTimers()
try {
runtimeCall.mockImplementation((args) => {
if (args.method === 'terminal.create') {
return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
}
if (args.method === 'terminal.send') {
return Promise.resolve({
ok: true,
result: { send: { handle: 'terminal-1', accepted: true, bytesWritten: 2 } }
})
}
return Promise.resolve({ ok: true, result: {} })
})
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1'
})
await transport.connect({ url: '', callbacks: {} })
subscriptionSendBinary.mockClear()
expect(transport.sendInput('a')).toBe(true)
await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(true)
await vi.runOnlyPendingTimersAsync()
expect(runtimeCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'terminal.send',
params: {
terminal: 'terminal-1',
text: 'a\x03',
client: { id: 'desktop:tab-1:pane:1', type: 'desktop' }
},
timeoutMs: 15_000
})
expect(subscriptionSendBinary).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('returns false when acknowledged terminal input is rejected by the runtime', async () => {
runtimeCall.mockImplementation((args) => {
if (args.method === 'terminal.create') {
return Promise.resolve({ ok: true, result: { terminal: { handle: 'terminal-1' } } })
}
if (args.method === 'terminal.send') {
return Promise.resolve({
ok: true,
result: { send: { handle: 'terminal-1', accepted: false, bytesWritten: 0 } }
})
}
return Promise.resolve({ ok: true, result: {} })
})
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', {
worktreeId: 'wt-1',
tabId: 'tab-1',
leafId: 'pane:1'
})
await transport.connect({ url: '', callbacks: {} })
await expect(transport.sendInputAccepted?.('\x03')).resolves.toBe(false)
})
it('normalizes bare LF input to carriage returns before writing to the remote PTY', async () => {
vi.useFakeTimers()
try {
@@ -2,7 +2,8 @@
import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope'
import type {
RuntimeMobileSessionTabsResult,
RuntimeTerminalCreate
RuntimeTerminalCreate,
RuntimeTerminalSend
} from '../../../../shared/runtime-types'
import type { PtyConnectResult, PtyTransport, IpcPtyTransportOptions } from './pty-dispatcher'
import { createPtyOutputProcessor } from './pty-transport'
@@ -212,6 +213,29 @@ export function createRemoteRuntimePtyTransport(
}
}
async function sendInputAcceptedToRuntime(data: string): Promise<boolean> {
const targetHandle = handle
if (!connected || !targetHandle) {
return false
}
const normalized = normalizeRemoteTerminalInput(data)
if (!normalized) {
return true
}
const text = `${inputBatcher.takePending()}${normalized}`
try {
const result = await callRuntime<{ send: RuntimeTerminalSend }>('terminal.send', {
terminal: targetHandle,
text,
client: { id: clientId, type: 'desktop' }
})
return result.send.accepted === true
} catch (error) {
storedCallbacks.onError?.(runtimeTerminalErrorMessage(error))
return false
}
}
const inputBatcher = createRemoteRuntimePtyTextBatcher(REMOTE_TERMINAL_INPUT_FLUSH_MS, (text) => {
const targetHandle = handle
if (!connected || !targetHandle) {
@@ -465,6 +489,8 @@ export function createRemoteRuntimePtyTransport(
return true
},
sendInputAccepted: sendInputAcceptedToRuntime,
resize(cols: number, rows: number): boolean {
if (!connected || !handle) {
return false
+1
View File
@@ -1071,6 +1071,7 @@ function createPtyApi(): NonNullable<Partial<PreloadApi>['pty']> {
return {
spawn: () => Promise.reject(new Error('Local PTYs are unavailable in the web client.')),
write: () => {},
writeAccepted: () => Promise.resolve(false),
resize: () => {},
reportGeometry: () => {},
signal: () => {},
+15 -1
View File
@@ -133,6 +133,11 @@ export type AgentHookEventPayload = {
* HTTP path always sets null because it cannot know which mux a request
* came from. See docs/design/agent-status-over-ssh.md §5. */
connectionId: string | null
/** True when this hook event carried prompt text directly, instead of using
* the listener's cached prompt from an earlier event in the same pane. */
hasExplicitPrompt?: boolean
/** True when this event is a relay cache replay rather than a live hook. */
isReplay?: boolean
payload: ParsedAgentStatusPayload
}
@@ -1973,7 +1978,16 @@ export function normalizeHookPayload(
// it null; the relay forwards null on the wire and Orca's `ingestRemote`
// stamps the real value from `mux` identity on receive. See
// docs/design/agent-status-over-ssh.md §5.
return payload ? { paneKey, tabId, worktreeId, connectionId: null, payload } : null
return payload
? {
paneKey,
tabId,
worktreeId,
connectionId: null,
hasExplicitPrompt: promptText.length > 0,
payload
}
: null
}
// ─── URL routing ────────────────────────────────────────────────────
+5
View File
@@ -54,6 +54,11 @@ export type AgentHookRelayEnvelope = {
worktreeId?: string
/** Always `null` on the wire — relay does not know Orca's local connectionId. */
connectionId: null
/** Preserved from the relay-side normalized hook event so Orca can
* distinguish a true same-prompt retry from a cached-prompt tool ping. */
hasExplicitPrompt?: boolean
/** True when the relay is replaying its cache after Orca reconnects. */
isReplay?: boolean
/** Forwarded from the agent CLI POST body. The relay default is `remote`,
* which marks transport location rather than dev/prod build env. */
env?: string
+173 -21
View File
@@ -1,5 +1,7 @@
/* eslint-disable max-lines -- Localhost SSH E2E covers setup, remote PTY, hook relay, and interrupt inference in one expensive app boot. */
import os from 'os'
import type { Page } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
@@ -72,6 +74,65 @@ function emitMarkerCommand(value: string): string {
)}`
}
async function focusTerminal(page: Page): Promise<void> {
await page.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('Store unavailable')
}
const state = store.getState()
const worktreeId = state.activeWorktreeId
if (!worktreeId) {
throw new Error('No active worktree')
}
const tabId =
state.activeTabType === 'terminal'
? state.activeTabId
: (state.activeTabIdByWorktree?.[worktreeId] ?? null)
if (!tabId) {
throw new Error('No active terminal tab')
}
const manager = window.__paneManagers?.get(tabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0]
if (!pane) {
throw new Error('No active terminal pane')
}
pane.terminal.focus()
})
}
async function postCodexHook(
page: Page,
ptyId: string,
payload: Record<string, unknown>,
markerName: string
): Promise<void> {
const hookPostedMarker = marker(markerName)
await execInTerminal(
page,
ptyId,
[
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' echo __ORCA_AGENT_HOOK_ENV_MISSING__',
'else',
` hook_payload=${shellQuote(JSON.stringify(payload))}`,
' if curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${hook_payload}" >/dev/null; then',
` ${emitMarkerCommand(hookPostedMarker)}`,
' fi',
'fi'
].join('\n')
)
await waitForTerminalOutput(page, hookPostedMarker, 20_000)
}
test.describe('Localhost SSH', () => {
test.skip(
!RUN_LOCALHOST_SSH,
@@ -201,6 +262,17 @@ test.describe('Localhost SSH', () => {
})
const paneKeyLeafId = paneKey.slice(paneKey.indexOf(':') + 1)
expect(paneKeyLeafId).toMatch(UUID_RE)
await orcaPage.evaluate(() => {
const state = window as unknown as {
__sshAgentStatusEvents?: unknown[]
__sshAgentStatusUnsubscribe?: () => void
}
state.__sshAgentStatusEvents = []
state.__sshAgentStatusUnsubscribe?.()
state.__sshAgentStatusUnsubscribe = window.api.agentStatus.onSet((event) => {
state.__sshAgentStatusEvents?.push(event)
})
})
const terminalMarker = marker('LOCALHOST_SSH')
await execInTerminal(orcaPage, ptyId, emitMarkerCommand(terminalMarker))
@@ -240,30 +312,12 @@ test.describe('Localhost SSH', () => {
await waitForTerminalOutput(orcaPage, pluginOverlayMarker, 20_000)
const prompt = `orca ssh e2e prompt ${Date.now()}`
const hookPostedMarker = marker('AGENT_HOOK_POSTED')
await execInTerminal(
await postCodexHook(
orcaPage,
ptyId,
[
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' echo __ORCA_AGENT_HOOK_ENV_MISSING__',
'else',
` hook_payload=${shellQuote(JSON.stringify({ hook_event_name: 'UserPromptSubmit', prompt }))}`,
' if curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/codex" \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload=${hook_payload}" >/dev/null; then',
` ${emitMarkerCommand(hookPostedMarker)}`,
' fi',
'fi'
].join('\n')
{ hook_event_name: 'UserPromptSubmit', prompt },
'AGENT_HOOK_POSTED'
)
await waitForTerminalOutput(orcaPage, hookPostedMarker, 20_000)
await expect
.poll(
@@ -292,5 +346,103 @@ test.describe('Localhost SSH', () => {
}
)
.toBe(true)
const ctrlPrompt = `orca ssh ctrl-c interrupt ${Date.now()}`
await postCodexHook(
orcaPage,
ptyId,
{ hook_event_name: 'UserPromptSubmit', prompt: ctrlPrompt },
'AGENT_HOOK_CTRL_WORKING'
)
await focusTerminal(orcaPage)
await orcaPage.keyboard.press('Control+C')
await orcaPage.waitForTimeout(750)
expect(
await orcaPage.evaluate(
({ paneKey, prompt, targetId, worktreeId }) => {
const state = window.__store?.getState()
const entry = state?.agentStatusByPaneKey[paneKey]
const events =
(
window as unknown as {
__sshAgentStatusEvents?: {
prompt?: string
connectionId?: string | null
worktreeId?: string
}[]
}
).__sshAgentStatusEvents ?? []
return {
state: entry?.state,
interrupted: entry?.interrupted,
prompt: entry?.prompt,
eventMatched: events.some(
(event) =>
event.prompt === prompt &&
event.connectionId === targetId &&
event.worktreeId === worktreeId
)
}
},
{ paneKey, prompt: ctrlPrompt, targetId: remote.targetId, worktreeId: remote.worktreeId }
)
).toEqual({
state: 'working',
interrupted: undefined,
prompt: ctrlPrompt,
eventMatched: true
})
await postCodexHook(
orcaPage,
ptyId,
{
hook_event_name: 'PreToolUse',
tool_name: 'exec_command',
tool_input: { cmd: '/bin/sleep 90' }
},
'AGENT_HOOK_LATE_WORKING'
)
await expect
.poll(
() =>
orcaPage.evaluate(
({ paneKey }) => {
const entry = window.__store?.getState().agentStatusByPaneKey[paneKey]
return {
state: entry?.state,
interrupted: entry?.interrupted,
prompt: entry?.prompt
}
},
{ paneKey }
),
{ timeout: 5_000, message: 'Late remote working hook did not remain working' }
)
.toEqual({ state: 'working', interrupted: undefined, prompt: ctrlPrompt })
const escapePrompt = `orca ssh escape interrupt ${Date.now()}`
await postCodexHook(
orcaPage,
ptyId,
{ hook_event_name: 'UserPromptSubmit', prompt: escapePrompt },
'AGENT_HOOK_ESCAPE_WORKING'
)
await focusTerminal(orcaPage)
await orcaPage.keyboard.press('Escape')
await orcaPage.waitForTimeout(750)
expect(
await orcaPage.evaluate(
({ paneKey }) => {
const entry = window.__store?.getState().agentStatusByPaneKey[paneKey]
return {
state: entry?.state,
interrupted: entry?.interrupted,
prompt: entry?.prompt
}
},
{ paneKey }
)
).toEqual({ state: 'working', interrupted: undefined, prompt: escapePrompt })
})
})