mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user