mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 08:02:32 +00:00
Infer interrupted agent status from terminal input (#2263)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
||||
} from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { AgentHookServer, _internals } from './server'
|
||||
import { AgentHookServer, agentHookServer, _internals } from './server'
|
||||
import {
|
||||
AGENT_STATUS_MAX_FIELD_LENGTH,
|
||||
parseAgentStatusPayload
|
||||
@@ -69,6 +69,855 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('AgentHookServer listener replay', () => {
|
||||
it('applies inferred interrupts through the cached status lifecycle', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'long task', agentType: 'codex' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
const applied = server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'long task',
|
||||
baselineAgentType: 'codex',
|
||||
intent: 'plain-escape'
|
||||
})
|
||||
|
||||
expect(applied).toBe(true)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
state: 'done',
|
||||
prompt: 'long task',
|
||||
agentType: 'codex',
|
||||
interrupted: true,
|
||||
receivedAt: 1_500,
|
||||
stateStartedAt: 1_500
|
||||
})
|
||||
])
|
||||
expect(listener).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
payload: expect.objectContaining({ state: 'done', interrupted: true })
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves an inferred interrupted row when OpenCode immediately reports SessionIdle', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
const listener = vi.fn()
|
||||
server.setListener(listener)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'long task', agentType: 'opencode' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'long task',
|
||||
baselineAgentType: 'opencode',
|
||||
intent: 'plain-escape',
|
||||
inputCount: 2
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
vi.setSystemTime(1_501)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'done', prompt: 'long task', agentType: 'opencode' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
state: 'done',
|
||||
prompt: 'long task',
|
||||
agentType: 'opencode',
|
||||
interrupted: true,
|
||||
receivedAt: 1_500,
|
||||
stateStartedAt: 1_500
|
||||
})
|
||||
])
|
||||
expect(listener).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
payload: expect.objectContaining({ state: 'done', interrupted: true })
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects inferred interrupts when a same-millisecond prompt update changed the row', () => {
|
||||
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: 'first task', agentType: 'codex' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'second task', agentType: 'codex' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
const applied = server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'first task',
|
||||
baselineAgentType: 'codex',
|
||||
intent: 'plain-escape'
|
||||
})
|
||||
|
||||
expect(applied).toBe(false)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'second task',
|
||||
agentType: 'codex'
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['opencode', 'copilot'] as const)(
|
||||
'rejects single plain Escape inference for %s',
|
||||
(agentType) => {
|
||||
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 }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
const applied = server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'long task',
|
||||
baselineAgentType: agentType,
|
||||
intent: 'plain-escape'
|
||||
})
|
||||
|
||||
expect(applied).toBe(false)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'long task',
|
||||
agentType
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['opencode', 'copilot'] as const)(
|
||||
'accepts double plain Escape inference for %s',
|
||||
(agentType) => {
|
||||
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 }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
const applied = server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'long task',
|
||||
baselineAgentType: agentType,
|
||||
intent: 'plain-escape',
|
||||
inputCount: 2
|
||||
})
|
||||
|
||||
expect(applied).toBe(true)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'done',
|
||||
prompt: 'long task',
|
||||
agentType,
|
||||
interrupted: true
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('rejects Ctrl+C inference for Droid', () => {
|
||||
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: 'droid' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
const applied = server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'long task',
|
||||
baselineAgentType: 'droid',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
|
||||
expect(applied).toBe(false)
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'long task',
|
||||
agentType: 'droid'
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let late same-turn working hooks resurrect an inferred interrupt', () => {
|
||||
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: 'pi' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot()[0]
|
||||
|
||||
vi.setSystemTime(1_500)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'long task',
|
||||
baselineAgentType: 'pi',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
vi.setSystemTime(6_000)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: {
|
||||
state: 'working',
|
||||
prompt: 'long task',
|
||||
agentType: 'pi',
|
||||
toolName: 'bash',
|
||||
toolInput: '/bin/sleep 90'
|
||||
}
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'done',
|
||||
prompt: 'long task',
|
||||
agentType: 'pi',
|
||||
interrupted: true,
|
||||
receivedAt: 1_500,
|
||||
stateStartedAt: 1_500
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('allows a new prompt 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',
|
||||
payload: { state: 'working', prompt: 'first 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: 'first task',
|
||||
baselineAgentType: 'pi',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
vi.setSystemTime(2_000)
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'second task', agentType: 'pi' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'working',
|
||||
prompt: 'second task',
|
||||
agentType: 'pi',
|
||||
receivedAt: 2_000,
|
||||
stateStartedAt: 2_000
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: PANE,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'waiting', prompt: 'permission', agentType: 'codex' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const waiting = server.getStatusSnapshot()[0]
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: PANE,
|
||||
baselineUpdatedAt: waiting.receivedAt,
|
||||
baselineStateStartedAt: waiting.stateStartedAt,
|
||||
baselinePrompt: 'permission',
|
||||
baselineAgentType: 'codex',
|
||||
intent: 'plain-escape'
|
||||
})
|
||||
).toBe(false)
|
||||
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: FRESH_PANE,
|
||||
tabId: 'tab-fresh',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'old task', agentType: 'codex' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const stale = server.getStatusSnapshot().find((entry) => entry.paneKey === FRESH_PANE)!
|
||||
vi.setSystemTime(stale.receivedAt + 30 * 60 * 1000 + 1)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: FRESH_PANE,
|
||||
baselineUpdatedAt: stale.receivedAt,
|
||||
baselineStateStartedAt: stale.stateStartedAt,
|
||||
baselinePrompt: 'old task',
|
||||
baselineAgentType: 'codex',
|
||||
intent: 'plain-escape'
|
||||
})
|
||||
).toBe(false)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('applies inferred interrupts for arbitrary agent types and Ctrl+C intent', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
try {
|
||||
const server = new AgentHookServer()
|
||||
server.ingestRemote(
|
||||
{
|
||||
paneKey: GOOD_PANE,
|
||||
tabId: 'tab-good',
|
||||
worktreeId: 'wt-1',
|
||||
payload: { state: 'working', prompt: 'custom task', agentType: 'custom-agent' }
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
const baseline = server.getStatusSnapshot().find((entry) => entry.paneKey === GOOD_PANE)!
|
||||
|
||||
vi.setSystemTime(1_250)
|
||||
expect(
|
||||
server.inferInterrupt({
|
||||
paneKey: GOOD_PANE,
|
||||
baselineUpdatedAt: baseline.receivedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: 'custom task',
|
||||
baselineAgentType: 'custom-agent',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
).toBe(true)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: GOOD_PANE,
|
||||
state: 'done',
|
||||
prompt: 'custom task',
|
||||
agentType: 'custom-agent',
|
||||
interrupted: true
|
||||
})
|
||||
])
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('allows multiple status-change subscribers to observe the same update', () => {
|
||||
const server = new AgentHookServer()
|
||||
const first = vi.fn()
|
||||
@@ -1242,6 +2091,76 @@ describe('Cursor hook normalization', () => {
|
||||
expect(result?.payload.lastAssistantMessage).toBe('Done — wrote the README.')
|
||||
})
|
||||
|
||||
it('late afterAgentResponse after stop keeps Cursor done instead of resurrecting working', () => {
|
||||
const submit = _internals.normalizeHookPayload(
|
||||
'cursor',
|
||||
buildBody({ hook_event_name: 'beforeSubmitPrompt', prompt: 'add tests' }),
|
||||
'production'
|
||||
)
|
||||
expect(submit).not.toBeNull()
|
||||
if (!submit) {
|
||||
throw new Error('expected Cursor beforeSubmitPrompt to normalize')
|
||||
}
|
||||
agentHookServer.ingestRemote(
|
||||
{
|
||||
paneKey: submit.paneKey,
|
||||
tabId: submit.tabId,
|
||||
worktreeId: submit.worktreeId,
|
||||
payload: submit.payload
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
const stop = _internals.normalizeHookPayload(
|
||||
'cursor',
|
||||
buildBody({ hook_event_name: 'stop', status: 'completed' }),
|
||||
'production'
|
||||
)
|
||||
expect(stop).not.toBeNull()
|
||||
if (!stop) {
|
||||
throw new Error('expected Cursor stop to normalize')
|
||||
}
|
||||
agentHookServer.ingestRemote(
|
||||
{
|
||||
paneKey: stop.paneKey,
|
||||
tabId: stop.tabId,
|
||||
worktreeId: stop.worktreeId,
|
||||
payload: stop.payload
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
|
||||
const response = _internals.normalizeHookPayload(
|
||||
'cursor',
|
||||
buildBody({ hook_event_name: 'afterAgentResponse', text: 'All set.' }),
|
||||
'production'
|
||||
)
|
||||
expect(response?.payload.state).toBe('done')
|
||||
expect(response?.payload.lastAssistantMessage).toBe('All set.')
|
||||
if (!response) {
|
||||
throw new Error('expected Cursor afterAgentResponse to normalize')
|
||||
}
|
||||
|
||||
agentHookServer.ingestRemote(
|
||||
{
|
||||
paneKey: response.paneKey,
|
||||
tabId: response.tabId,
|
||||
worktreeId: response.worktreeId,
|
||||
payload: response.payload
|
||||
},
|
||||
'conn-1'
|
||||
)
|
||||
expect(agentHookServer.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
paneKey: PANE,
|
||||
state: 'done',
|
||||
agentType: 'cursor',
|
||||
prompt: 'add tests',
|
||||
lastAssistantMessage: 'All set.'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('beforeSubmitPrompt clears the cached tool state from a prior turn', () => {
|
||||
_internals.normalizeHookPayload(
|
||||
'cursor',
|
||||
|
||||
@@ -36,10 +36,16 @@ import {
|
||||
} from '../../shared/agent-hook-listener'
|
||||
import type { AgentHookSource } from '../../shared/agent-hook-relay'
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStatusIpcPayload,
|
||||
type AgentType,
|
||||
type AgentStatusState,
|
||||
normalizeAgentStatusPayload
|
||||
} from '../../shared/agent-status-types'
|
||||
import {
|
||||
isAgentInterruptInputIntent,
|
||||
type AgentInterruptInferenceRequest
|
||||
} from '../../shared/agent-interrupt-intent'
|
||||
import { parseLegacyNumericPaneKey, parsePaneKey } from '../../shared/stable-pane-id'
|
||||
import type { LegacyPaneKeyAliasEntry } from '../../shared/types'
|
||||
|
||||
@@ -78,6 +84,7 @@ type PaneKeyAliasEntry = {
|
||||
const LAST_STATUS_FILE_NAME = 'last-status.json'
|
||||
const ASSISTANT_MESSAGE_RETRY_ATTEMPTS = 5
|
||||
const ASSISTANT_MESSAGE_RETRY_MS = 50
|
||||
const INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS = 15_000
|
||||
|
||||
// Why: starts at 2 (not 1) because pre-merge dev iterations of this branch
|
||||
// wrote a v1 shape with no receivedAt / stateStartedAt. Bumping to 2 means a
|
||||
@@ -106,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 {
|
||||
@@ -173,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
|
||||
@@ -279,6 +296,70 @@ export class AgentHookServer {
|
||||
)
|
||||
}
|
||||
|
||||
inferInterrupt(request: AgentInterruptInferenceRequest): boolean {
|
||||
if (!isValidPaneKey(request.paneKey)) {
|
||||
return false
|
||||
}
|
||||
if (!isAgentInterruptInputIntent(request.intent)) {
|
||||
return false
|
||||
}
|
||||
const existing = this.state.lastStatusByPaneKey.get(request.paneKey) as
|
||||
| EnrichedAgentHookEventPayload
|
||||
| undefined
|
||||
if (!existing) {
|
||||
return false
|
||||
}
|
||||
const payload = existing.payload
|
||||
const agentType: AgentType | undefined = payload.agentType
|
||||
// Why: Droid's Ctrl+C does not interrupt the current turn; repeated Ctrl+C
|
||||
// exits the CLI, which is handled by process/PTY lifecycle cleanup.
|
||||
if (agentType === 'droid' && request.intent === 'ctrl-c') {
|
||||
return false
|
||||
}
|
||||
// Why: these agents use the first Escape as a TUI/editor cancel. A single
|
||||
// Escape can leave the turn running, so only a deliberate double Escape
|
||||
// may infer an interrupted turn.
|
||||
if (
|
||||
(agentType === 'opencode' || agentType === 'copilot') &&
|
||||
request.intent === 'plain-escape' &&
|
||||
request.inputCount !== 2
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Why: input-intent inference is a fallback for a missing final hook. A strict
|
||||
// baseline match keeps a delayed timer from overwriting any newer hook,
|
||||
// including same-millisecond prompt or agent identity changes.
|
||||
if (
|
||||
payload.state !== 'working' ||
|
||||
!equivalentInterruptAgentType(agentType, request.baselineAgentType) ||
|
||||
payload.prompt !== request.baselinePrompt ||
|
||||
existing.receivedAt !== request.baselineUpdatedAt ||
|
||||
existing.stateStartedAt !== request.baselineStateStartedAt ||
|
||||
Date.now() - existing.receivedAt > AGENT_STATUS_STALE_AFTER_MS
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const inferred = this.applyNormalizedStatus({
|
||||
paneKey: existing.paneKey,
|
||||
tabId: existing.tabId,
|
||||
worktreeId: existing.worktreeId,
|
||||
connectionId: existing.connectionId,
|
||||
payload: {
|
||||
state: 'done',
|
||||
prompt: payload.prompt,
|
||||
agentType,
|
||||
interrupted: true
|
||||
}
|
||||
})
|
||||
console.debug('[agent-hooks] inferred interrupted agent status', {
|
||||
paneKey: inferred.paneKey,
|
||||
agentType,
|
||||
intent: request.intent
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] {
|
||||
return Array.from(this.state.lastStatusByPaneKey.entries(), ([paneKey, entry]) => {
|
||||
const enriched = entry as EnrichedAgentHookEventPayload
|
||||
@@ -319,6 +400,33 @@ export class AgentHookServer {
|
||||
}
|
||||
|
||||
private applyNormalizedStatus(payload: AgentHookEventPayload): EnrichedAgentHookEventPayload {
|
||||
const previous = this.state.lastStatusByPaneKey.get(payload.paneKey) as
|
||||
| EnrichedAgentHookEventPayload
|
||||
| undefined
|
||||
// Why: some TUIs can emit a delayed tool/working hook after Ctrl+C already
|
||||
// stopped the turn. Do not let that stale same-turn event resurrect the row.
|
||||
if (
|
||||
previous?.payload.state === 'done' &&
|
||||
previous.payload.interrupted === true &&
|
||||
payload.payload.state === 'done' &&
|
||||
previous.payload.agentType === payload.payload.agentType &&
|
||||
previous.payload.prompt === payload.payload.prompt &&
|
||||
Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS
|
||||
) {
|
||||
return previous
|
||||
}
|
||||
if (
|
||||
previous?.payload.state === 'done' &&
|
||||
previous.payload.interrupted === true &&
|
||||
payload.payload.state === 'working' &&
|
||||
previous.payload.agentType === payload.payload.agentType &&
|
||||
previous.payload.prompt === payload.payload.prompt &&
|
||||
(payload.isReplay === true ||
|
||||
(payload.hasExplicitPrompt !== true &&
|
||||
Date.now() - previous.receivedAt <= INTERRUPTED_DONE_LATE_WORKING_SUPPRESSION_MS))
|
||||
) {
|
||||
return previous
|
||||
}
|
||||
if (payload.payload.state !== 'done' || payload.payload.lastAssistantMessage) {
|
||||
this.clearAssistantMessageRetry(payload.paneKey)
|
||||
}
|
||||
@@ -517,6 +625,8 @@ export class AgentHookServer {
|
||||
worktreeId?: string
|
||||
env?: string
|
||||
version?: string
|
||||
hasExplicitPrompt?: boolean
|
||||
isReplay?: boolean
|
||||
payload: unknown
|
||||
},
|
||||
connectionId: string
|
||||
@@ -592,6 +702,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)
|
||||
|
||||
@@ -245,6 +245,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.sendNotification('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)
|
||||
}
|
||||
|
||||
@@ -197,6 +197,39 @@ describe('createPtySubprocess', () => {
|
||||
expect(env.ORCA_WORKTREE_ID).toBe('child-worktree')
|
||||
})
|
||||
|
||||
it('does not inherit parent agent hook endpoint for development hook env', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
const previousEndpoint = process.env.ORCA_AGENT_HOOK_ENDPOINT
|
||||
process.env.ORCA_AGENT_HOOK_ENDPOINT = '/tmp/stale-endpoint.env'
|
||||
|
||||
try {
|
||||
createPtySubprocess({
|
||||
sessionId: 'test',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: {
|
||||
ORCA_AGENT_HOOK_ENV: 'development',
|
||||
ORCA_AGENT_HOOK_PORT: '1234',
|
||||
ORCA_AGENT_HOOK_TOKEN: 'token',
|
||||
ORCA_AGENT_HOOK_VERSION: '1'
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
if (previousEndpoint === undefined) {
|
||||
delete process.env.ORCA_AGENT_HOOK_ENDPOINT
|
||||
} else {
|
||||
process.env.ORCA_AGENT_HOOK_ENDPOINT = previousEndpoint
|
||||
}
|
||||
}
|
||||
|
||||
const env = spawnMock.mock.calls.at(-1)?.[2].env
|
||||
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
|
||||
expect(env.ORCA_AGENT_HOOK_ENV).toBe('development')
|
||||
expect(env.ORCA_AGENT_HOOK_PORT).toBe('1234')
|
||||
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('token')
|
||||
})
|
||||
|
||||
it('forwards write calls', () => {
|
||||
const proc = mockPtyProcess()
|
||||
spawnMock.mockReturnValue(proc)
|
||||
|
||||
@@ -63,6 +63,17 @@ function removeUnspecifiedPaneIdentityEnv(
|
||||
}
|
||||
}
|
||||
|
||||
function removeInheritedDevAgentHookEndpoint(
|
||||
env: Record<string, string>,
|
||||
explicitEnv: Record<string, string> | undefined
|
||||
): void {
|
||||
if (explicitEnv?.ORCA_AGENT_HOOK_ENV === 'development') {
|
||||
// Why: the daemon inherits the app process env before per-PTY env is
|
||||
// merged, so dev terminals must explicitly drop a parent endpoint.env.
|
||||
delete env.ORCA_AGENT_HOOK_ENDPOINT
|
||||
}
|
||||
}
|
||||
|
||||
function formatMissingDaemonPathError(kind: 'helper' | 'cwd', path: string): DaemonProtocolError {
|
||||
const detailName = kind === 'helper' ? 'helper' : 'cwd'
|
||||
const step = kind === 'helper' ? 'posix_spawn' : 'daemon_cwd'
|
||||
@@ -165,6 +176,7 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl
|
||||
// Why: the daemon is forked from Electron and can inherit the pane identity
|
||||
// of the terminal that launched `pn dev`; each PTY must opt into its own.
|
||||
removeUnspecifiedPaneIdentityEnv(env, opts.env)
|
||||
removeInheritedDevAgentHookEndpoint(env, opts.env)
|
||||
removeInheritedNoColor(env)
|
||||
|
||||
env.LANG ??= 'en_US.UTF-8'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AgentStatusIpcPayload,
|
||||
MigrationUnsupportedPtyEntry
|
||||
} from '../../shared/agent-status-types'
|
||||
import type { AgentInterruptInferenceRequest } from '../../shared/agent-interrupt-intent'
|
||||
import { agentHookServer, isValidPaneKey } from '../agent-hooks/server'
|
||||
import {
|
||||
clearMigrationUnsupportedPtysForPaneKey,
|
||||
@@ -38,6 +39,7 @@ export function registerAgentHookHandlers(): void {
|
||||
ipcMain.removeHandler('agentHooks:copilotStatus')
|
||||
ipcMain.removeHandler('agentHooks:hermesStatus')
|
||||
ipcMain.removeHandler('agentStatus:getSnapshot')
|
||||
ipcMain.removeHandler('agentStatus:inferInterrupt')
|
||||
ipcMain.removeHandler('agentStatus:getMigrationUnsupportedSnapshot')
|
||||
// Why: agentStatus:drop is sent fire-and-forget from the renderer via
|
||||
// ipcRenderer.send(); we listen with ipcMain.on (not handle) so we don't
|
||||
@@ -64,6 +66,12 @@ export function registerAgentHookHandlers(): void {
|
||||
// lose replayed statuses while its local store is still empty.
|
||||
return agentHookServer.getStatusSnapshot()
|
||||
})
|
||||
ipcMain.handle('agentStatus:inferInterrupt', (_event, request: unknown): boolean => {
|
||||
if (typeof request !== 'object' || request === null) {
|
||||
return false
|
||||
}
|
||||
return agentHookServer.inferInterrupt(request as AgentInterruptInferenceRequest)
|
||||
})
|
||||
ipcMain.handle(
|
||||
'agentStatus:getMigrationUnsupportedSnapshot',
|
||||
(): MigrationUnsupportedPtyEntry[] => getMigrationUnsupportedPtySnapshot()
|
||||
|
||||
@@ -192,6 +192,7 @@ describe('registerPtyHandlers', () => {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
delete process.env.ORCA_OPENCODE_SOURCE_CONFIG_DIR
|
||||
delete process.env.ORCA_OPENCODE_CONFIG_DIR
|
||||
delete process.env.ORCA_AGENT_HOOK_ENDPOINT
|
||||
delete process.env.PI_CODING_AGENT_DIR
|
||||
delete process.env.ORCA_PI_SOURCE_AGENT_DIR
|
||||
delete process.env.ORCA_PI_CODING_AGENT_DIR
|
||||
@@ -805,6 +806,23 @@ describe('registerPtyHandlers', () => {
|
||||
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
|
||||
})
|
||||
|
||||
it('strips inherited agent-hook endpoint env from development daemon PTYs', async () => {
|
||||
const { app } = await import('electron')
|
||||
const mockedApp = app as unknown as { isPackaged: boolean }
|
||||
const prev = mockedApp.isPackaged
|
||||
mockedApp.isPackaged = false
|
||||
try {
|
||||
const env = await daemonSpawnAndGetEnv({}, undefined, undefined, {
|
||||
ORCA_AGENT_HOOK_ENDPOINT: '/tmp/stale-endpoint.env'
|
||||
})
|
||||
expect(env.ORCA_AGENT_HOOK_ENDPOINT).toBeUndefined()
|
||||
expect(env.ORCA_AGENT_HOOK_PORT).toBe('5678')
|
||||
expect(env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token')
|
||||
} finally {
|
||||
mockedApp.isPackaged = prev
|
||||
}
|
||||
})
|
||||
|
||||
it('prepends attribution shims on the daemon path', async () => {
|
||||
const env = await daemonSpawnAndGetEnv({}, undefined, () => ({
|
||||
enableGitHubAttribution: true
|
||||
@@ -2622,6 +2640,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'
|
||||
|
||||
+45
-5
@@ -313,6 +313,9 @@ export function buildPtyHostEnv(
|
||||
// must inject the loopback receiver coordinates before the agent starts.
|
||||
// Without these env vars the global hook config cannot map callbacks back
|
||||
// to the correct Orca pane.
|
||||
// Why: nested Orca terminals can inherit another process's hook endpoint or
|
||||
// token. Strip all hook runtime coordinates before injecting this PTY's fresh
|
||||
// server values so callbacks route to the owning app/runtime.
|
||||
for (const key of AGENT_HOOK_RUNTIME_ENV_KEYS) {
|
||||
delete baseEnv[key]
|
||||
}
|
||||
@@ -583,6 +586,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')
|
||||
@@ -1607,7 +1611,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
|
||||
@@ -1615,14 +1619,50 @@ 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
|
||||
}
|
||||
const provider = ptyOwnership.has(args.id) ? tryGetProviderForPty(args.id) : undefined
|
||||
if (!provider) {
|
||||
return
|
||||
return false
|
||||
}
|
||||
lastInputAtByPty.set(args.id, performance.now())
|
||||
provider.write(args.id, args.data)
|
||||
try {
|
||||
lastInputAtByPty.set(args.id, performance.now())
|
||||
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 {
|
||||
lastInputAtByPty.set(args.id, performance.now())
|
||||
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
|
||||
|
||||
@@ -161,6 +161,7 @@ import type {
|
||||
AgentStatusIpcPayload,
|
||||
MigrationUnsupportedPtyEntry
|
||||
} from '../shared/agent-status-types'
|
||||
import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent'
|
||||
import type {
|
||||
RuntimeBrowserDriverState,
|
||||
RuntimeMobileSessionTabMove,
|
||||
@@ -708,6 +709,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
|
||||
@@ -1921,6 +1923,7 @@ export type PreloadApi = {
|
||||
onSet: (callback: (data: AgentStatusIpcPayload) => void) => () => void
|
||||
/** Return the current main-process hook cache after renderer hydration. */
|
||||
getSnapshot: () => Promise<AgentStatusIpcPayload[]>
|
||||
inferInterrupt: (request: AgentInterruptInferenceRequest) => Promise<boolean>
|
||||
/** Listen for PTYs that still use a legacy numeric pane key but have
|
||||
* registry-backed UUID pane proof. */
|
||||
onMigrationUnsupported: (callback: (entry: MigrationUnsupportedPtyEntry) => void) => () => void
|
||||
|
||||
@@ -103,6 +103,7 @@ import type {
|
||||
AgentStatusIpcPayload,
|
||||
MigrationUnsupportedPtyEntry
|
||||
} from '../shared/agent-status-types'
|
||||
import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent'
|
||||
import type {
|
||||
SpeechErrorEvent,
|
||||
SpeechLifecycleEvent,
|
||||
@@ -605,6 +606,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 })
|
||||
@@ -3051,6 +3054,8 @@ const api = {
|
||||
* knows which tabs exist. */
|
||||
getSnapshot: (): Promise<AgentStatusIpcPayload[]> =>
|
||||
ipcRenderer.invoke('agentStatus:getSnapshot'),
|
||||
inferInterrupt: (request: AgentInterruptInferenceRequest): Promise<boolean> =>
|
||||
ipcRenderer.invoke('agentStatus:inferInterrupt', request),
|
||||
onMigrationUnsupported: (
|
||||
callback: (entry: MigrationUnsupportedPtyEntry) => void
|
||||
): (() => void) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
/* eslint-disable max-lines -- Why: this suite locks the interrupt inference state machine across several agent-specific keyboard semantics. */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import {
|
||||
createAgentInterruptInference,
|
||||
isCtrlCKeyEvent,
|
||||
isPlainEscapeKeyEvent
|
||||
} from './agent-interrupt-inference'
|
||||
|
||||
const PANE_KEY = 'tab-1:11111111-1111-4111-8111-111111111111'
|
||||
|
||||
function makeEntry(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
|
||||
return {
|
||||
state: 'working',
|
||||
prompt: 'write tests',
|
||||
updatedAt: 1_000,
|
||||
stateStartedAt: 900,
|
||||
agentType: 'codex',
|
||||
paneKey: PANE_KEY,
|
||||
terminalTitle: 'Codex',
|
||||
stateHistory: [],
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
it.each([['ctrl-c', 'custom-agent']] as const)(
|
||||
'emits a strict baseline request for %s from %s after the settle window',
|
||||
(intent, agentType) => {
|
||||
vi.useFakeTimers()
|
||||
let entry: AgentStatusEntry | undefined = makeEntry({ agentType })
|
||||
const inferInterrupt = vi.fn()
|
||||
const tracker = createAgentInterruptInference({
|
||||
paneKey: PANE_KEY,
|
||||
getStatusEntry: () => entry,
|
||||
inferInterrupt,
|
||||
now: () => 1_100
|
||||
})
|
||||
|
||||
tracker.observeInputIntent(intent)
|
||||
vi.advanceTimersByTime(499)
|
||||
expect(inferInterrupt).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(1)
|
||||
|
||||
expect(inferInterrupt).toHaveBeenCalledWith({
|
||||
paneKey: PANE_KEY,
|
||||
baselineUpdatedAt: 1_000,
|
||||
baselineStateStartedAt: 900,
|
||||
baselinePrompt: 'write tests',
|
||||
baselineAgentType: agentType,
|
||||
intent
|
||||
})
|
||||
tracker.dispose()
|
||||
entry = undefined
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['plain-escape', 'gemini'],
|
||||
['ctrl-c', 'gemini']
|
||||
] as const)(
|
||||
'emits a strict baseline request for %s from Gemini immediately',
|
||||
(intent, agentType) => {
|
||||
vi.useFakeTimers()
|
||||
let entry: AgentStatusEntry | undefined = makeEntry({ agentType })
|
||||
const inferInterrupt = vi.fn()
|
||||
const tracker = createAgentInterruptInference({
|
||||
paneKey: PANE_KEY,
|
||||
getStatusEntry: () => entry,
|
||||
inferInterrupt,
|
||||
now: () => 1_100
|
||||
})
|
||||
|
||||
tracker.observeInputIntent(intent)
|
||||
|
||||
expect(inferInterrupt).toHaveBeenCalledWith({
|
||||
paneKey: PANE_KEY,
|
||||
baselineUpdatedAt: 1_000,
|
||||
baselineStateStartedAt: 900,
|
||||
baselinePrompt: 'write tests',
|
||||
baselineAgentType: agentType,
|
||||
intent
|
||||
})
|
||||
tracker.dispose()
|
||||
entry = undefined
|
||||
}
|
||||
)
|
||||
|
||||
it('emits when the working row has no agent type', () => {
|
||||
vi.useFakeTimers()
|
||||
let entry: AgentStatusEntry | undefined = makeEntry({ agentType: undefined })
|
||||
const inferInterrupt = vi.fn()
|
||||
const tracker = createAgentInterruptInference({
|
||||
paneKey: PANE_KEY,
|
||||
getStatusEntry: () => entry,
|
||||
inferInterrupt,
|
||||
now: () => 1_100
|
||||
})
|
||||
|
||||
tracker.observeInputIntent('ctrl-c')
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
expect(inferInterrupt).toHaveBeenCalledWith({
|
||||
paneKey: PANE_KEY,
|
||||
baselineUpdatedAt: 1_000,
|
||||
baselineStateStartedAt: 900,
|
||||
baselinePrompt: 'write tests',
|
||||
baselineAgentType: undefined,
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
tracker.dispose()
|
||||
entry = undefined
|
||||
})
|
||||
|
||||
it('does not infer Ctrl+C for Droid', () => {
|
||||
vi.useFakeTimers()
|
||||
let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'droid' })
|
||||
const inferInterrupt = vi.fn()
|
||||
const tracker = createAgentInterruptInference({
|
||||
paneKey: PANE_KEY,
|
||||
getStatusEntry: () => entry,
|
||||
inferInterrupt,
|
||||
now: () => 1_100
|
||||
})
|
||||
|
||||
tracker.observeInputIntent('ctrl-c')
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
expect(inferInterrupt).not.toHaveBeenCalled()
|
||||
tracker.dispose()
|
||||
entry = undefined
|
||||
})
|
||||
|
||||
it.each(['opencode', 'copilot'] as const)(
|
||||
'infers immediately on double Escape for %s',
|
||||
(agentType) => {
|
||||
vi.useFakeTimers()
|
||||
let entry: AgentStatusEntry | undefined = makeEntry({ agentType })
|
||||
const inferInterrupt = vi.fn()
|
||||
const tracker = createAgentInterruptInference({
|
||||
paneKey: PANE_KEY,
|
||||
getStatusEntry: () => entry,
|
||||
inferInterrupt,
|
||||
now: () => 1_100
|
||||
})
|
||||
|
||||
tracker.observeInputIntent('plain-escape')
|
||||
expect(inferInterrupt).not.toHaveBeenCalled()
|
||||
|
||||
tracker.observeInputIntent('plain-escape')
|
||||
|
||||
expect(inferInterrupt).toHaveBeenCalledWith({
|
||||
paneKey: PANE_KEY,
|
||||
baselineUpdatedAt: 1_000,
|
||||
baselineStateStartedAt: 900,
|
||||
baselinePrompt: 'write tests',
|
||||
baselineAgentType: agentType,
|
||||
intent: 'plain-escape',
|
||||
inputCount: 2
|
||||
})
|
||||
tracker.dispose()
|
||||
entry = undefined
|
||||
}
|
||||
)
|
||||
|
||||
it('does not count an OpenCode Escape across a new turn', () => {
|
||||
vi.useFakeTimers()
|
||||
let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'opencode' })
|
||||
const inferInterrupt = vi.fn()
|
||||
const tracker = createAgentInterruptInference({
|
||||
paneKey: PANE_KEY,
|
||||
getStatusEntry: () => entry,
|
||||
inferInterrupt,
|
||||
now: () => 1_100
|
||||
})
|
||||
|
||||
tracker.observeInputIntent('plain-escape')
|
||||
entry = makeEntry({ agentType: 'opencode', prompt: 'second task', stateStartedAt: 1_050 })
|
||||
tracker.observeInputIntent('plain-escape')
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(inferInterrupt).not.toHaveBeenCalled()
|
||||
tracker.dispose()
|
||||
entry = undefined
|
||||
})
|
||||
|
||||
it.each(['opencode', 'copilot'] as const)(
|
||||
'does not count a %s Escape after the double-Escape window expires',
|
||||
(agentType) => {
|
||||
vi.useFakeTimers()
|
||||
let entry: AgentStatusEntry | undefined = makeEntry({ agentType })
|
||||
const inferInterrupt = vi.fn()
|
||||
const tracker = createAgentInterruptInference({
|
||||
paneKey: PANE_KEY,
|
||||
getStatusEntry: () => entry,
|
||||
inferInterrupt,
|
||||
now: () => 1_100
|
||||
})
|
||||
|
||||
tracker.observeInputIntent('plain-escape')
|
||||
vi.advanceTimersByTime(500)
|
||||
tracker.observeInputIntent('plain-escape')
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(inferInterrupt).not.toHaveBeenCalled()
|
||||
tracker.dispose()
|
||||
entry = undefined
|
||||
}
|
||||
)
|
||||
|
||||
it('does not emit again for a third OpenCode Escape after the row is already done', () => {
|
||||
vi.useFakeTimers()
|
||||
let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'opencode' })
|
||||
const inferInterrupt = vi.fn((request) => {
|
||||
entry = makeEntry({
|
||||
state: 'done',
|
||||
agentType: request.baselineAgentType,
|
||||
prompt: request.baselinePrompt,
|
||||
updatedAt: 1_500,
|
||||
stateStartedAt: 1_500
|
||||
})
|
||||
})
|
||||
const tracker = createAgentInterruptInference({
|
||||
paneKey: PANE_KEY,
|
||||
getStatusEntry: () => entry,
|
||||
inferInterrupt,
|
||||
now: () => 1_100
|
||||
})
|
||||
|
||||
tracker.observeInputIntent('plain-escape')
|
||||
tracker.observeInputIntent('plain-escape')
|
||||
tracker.observeInputIntent('plain-escape')
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(inferInterrupt).toHaveBeenCalledTimes(1)
|
||||
tracker.dispose()
|
||||
entry = undefined
|
||||
})
|
||||
|
||||
it('still infers Ctrl+C for OpenCode', () => {
|
||||
vi.useFakeTimers()
|
||||
let entry: AgentStatusEntry | undefined = makeEntry({ agentType: 'opencode' })
|
||||
const inferInterrupt = vi.fn()
|
||||
const tracker = createAgentInterruptInference({
|
||||
paneKey: PANE_KEY,
|
||||
getStatusEntry: () => entry,
|
||||
inferInterrupt,
|
||||
now: () => 1_100
|
||||
})
|
||||
|
||||
tracker.observeInputIntent('ctrl-c')
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
expect(inferInterrupt).toHaveBeenCalledWith({
|
||||
paneKey: PANE_KEY,
|
||||
baselineUpdatedAt: 1_000,
|
||||
baselineStateStartedAt: 900,
|
||||
baselinePrompt: 'write tests',
|
||||
baselineAgentType: 'opencode',
|
||||
intent: 'ctrl-c'
|
||||
})
|
||||
tracker.dispose()
|
||||
entry = undefined
|
||||
})
|
||||
|
||||
it('does not emit for non-working states', () => {
|
||||
vi.useFakeTimers()
|
||||
const inferInterrupt = vi.fn()
|
||||
let entry: AgentStatusEntry | undefined = makeEntry({ state: 'waiting', agentType: 'codex' })
|
||||
const tracker = createAgentInterruptInference({
|
||||
paneKey: PANE_KEY,
|
||||
getStatusEntry: () => entry,
|
||||
inferInterrupt,
|
||||
now: () => 1_100
|
||||
})
|
||||
|
||||
tracker.observeInputIntent('plain-escape')
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(inferInterrupt).not.toHaveBeenCalled()
|
||||
tracker.dispose()
|
||||
})
|
||||
|
||||
it('cancels when a newer hook update 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('plain-escape')
|
||||
entry = makeEntry({ updatedAt: 1_001 })
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
expect(inferInterrupt).not.toHaveBeenCalled()
|
||||
tracker.dispose()
|
||||
})
|
||||
|
||||
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()
|
||||
const tracker = createAgentInterruptInference({
|
||||
paneKey: PANE_KEY,
|
||||
getStatusEntry: () => entry,
|
||||
inferInterrupt,
|
||||
now: () => 1_100
|
||||
})
|
||||
|
||||
tracker.observeInputIntent('plain-escape')
|
||||
entry = undefined
|
||||
vi.advanceTimersByTime(500)
|
||||
|
||||
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(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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,242 @@
|
||||
import {
|
||||
AGENT_STATUS_STALE_AFTER_MS,
|
||||
type AgentStatusEntry
|
||||
} from '../../../../shared/agent-status-types'
|
||||
import {
|
||||
AGENT_INTERRUPT_SETTLE_MS,
|
||||
type AgentInterruptInferenceRequest,
|
||||
type AgentInterruptInputIntent
|
||||
} from '../../../../shared/agent-interrupt-intent'
|
||||
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) => boolean | Promise<boolean> | void
|
||||
now?: () => number
|
||||
setTimer?: (callback: () => void, ms: number) => ReturnType<typeof setTimeout>
|
||||
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void
|
||||
}
|
||||
|
||||
type CapturedInterruptBaseline = {
|
||||
updatedAt: number
|
||||
stateStartedAt: number
|
||||
prompt: string
|
||||
agentType: AgentStatusEntry['agentType']
|
||||
intent: AgentInterruptInputIntent
|
||||
inputCount?: number
|
||||
}
|
||||
|
||||
function requiresDoubleEscapeForAgent(
|
||||
agentType: AgentStatusEntry['agentType'],
|
||||
intent: AgentInterruptInputIntent
|
||||
): boolean {
|
||||
return (agentType === 'opencode' || agentType === 'copilot') && intent === 'plain-escape'
|
||||
}
|
||||
|
||||
function shouldFlushInterruptImmediately(
|
||||
baseline: Pick<CapturedInterruptBaseline, 'agentType' | 'intent'>
|
||||
): boolean {
|
||||
return (
|
||||
requiresDoubleEscapeForAgent(baseline.agentType, baseline.intent) ||
|
||||
baseline.agentType === 'gemini'
|
||||
)
|
||||
}
|
||||
|
||||
function shouldIgnoreInterruptIntent(
|
||||
agentType: AgentStatusEntry['agentType'],
|
||||
intent: AgentInterruptInputIntent
|
||||
): boolean {
|
||||
return agentType === 'droid' && intent === 'ctrl-c'
|
||||
}
|
||||
|
||||
function isSameTurnBaseline(
|
||||
left: CapturedInterruptBaseline,
|
||||
right: CapturedInterruptBaseline
|
||||
): boolean {
|
||||
return (
|
||||
left.agentType === right.agentType &&
|
||||
left.prompt === right.prompt &&
|
||||
left.stateStartedAt === right.stateStartedAt
|
||||
)
|
||||
}
|
||||
|
||||
export function isPlainEscapeKeyEvent(
|
||||
event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey' | 'repeat'>
|
||||
): boolean {
|
||||
return (
|
||||
event.key === 'Escape' &&
|
||||
!event.repeat &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey
|
||||
)
|
||||
}
|
||||
|
||||
export function isCtrlCKeyEvent(
|
||||
event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey' | 'repeat'>
|
||||
): boolean {
|
||||
return (
|
||||
event.key.toLowerCase() === 'c' &&
|
||||
!event.repeat &&
|
||||
event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey
|
||||
)
|
||||
}
|
||||
|
||||
export function createAgentInterruptInference({
|
||||
paneKey,
|
||||
getStatusEntry,
|
||||
inferInterrupt,
|
||||
now = () => Date.now(),
|
||||
setTimer = (callback, ms) => setTimeout(callback, ms),
|
||||
clearTimer = (timer) => clearTimeout(timer)
|
||||
}: AgentInterruptInferenceDeps): AgentInterruptInference {
|
||||
let pendingTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pendingBaseline: CapturedInterruptBaseline | null = null
|
||||
let doubleEscapeBaseline: CapturedInterruptBaseline | null = null
|
||||
let doubleEscapeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const clearPendingTimer = (): void => {
|
||||
if (pendingTimer !== null) {
|
||||
clearTimer(pendingTimer)
|
||||
pendingTimer = null
|
||||
}
|
||||
pendingBaseline = null
|
||||
}
|
||||
|
||||
const clearDoubleEscapeBaseline = (): void => {
|
||||
doubleEscapeBaseline = null
|
||||
if (doubleEscapeTimer !== null) {
|
||||
clearTimer(doubleEscapeTimer)
|
||||
doubleEscapeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const clearPending = (): void => {
|
||||
clearPendingTimer()
|
||||
clearDoubleEscapeBaseline()
|
||||
}
|
||||
|
||||
const captureBaseline = (
|
||||
entry: AgentStatusEntry,
|
||||
intent: AgentInterruptInputIntent
|
||||
): CapturedInterruptBaseline | null => {
|
||||
const agentType = entry.agentType
|
||||
if (
|
||||
entry.state !== 'working' ||
|
||||
!isExplicitAgentStatusFresh(entry, now(), AGENT_STATUS_STALE_AFTER_MS)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
updatedAt: entry.updatedAt,
|
||||
stateStartedAt: entry.stateStartedAt,
|
||||
prompt: entry.prompt,
|
||||
agentType,
|
||||
intent
|
||||
}
|
||||
}
|
||||
|
||||
const flushPending = (): boolean | Promise<boolean> => {
|
||||
const baseline = pendingBaseline
|
||||
pendingTimer = null
|
||||
pendingBaseline = null
|
||||
if (!baseline) {
|
||||
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))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (!entry && now() - baseline.updatedAt > AGENT_STATUS_STALE_AFTER_MS) {
|
||||
return false
|
||||
}
|
||||
|
||||
const result = inferInterrupt({
|
||||
paneKey,
|
||||
baselineUpdatedAt: baseline.updatedAt,
|
||||
baselineStateStartedAt: baseline.stateStartedAt,
|
||||
baselinePrompt: baseline.prompt,
|
||||
baselineAgentType: baseline.agentType,
|
||||
intent: baseline.intent,
|
||||
...(baseline.inputCount !== undefined ? { inputCount: baseline.inputCount } : {})
|
||||
})
|
||||
return result ?? true
|
||||
}
|
||||
|
||||
const flushPendingFromTimer = (): void => {
|
||||
void flushPending()
|
||||
}
|
||||
|
||||
return {
|
||||
observeInputIntent(intent) {
|
||||
const entry = getStatusEntry()
|
||||
if (!entry) {
|
||||
clearPending()
|
||||
return
|
||||
}
|
||||
let baseline = captureBaseline(entry, intent)
|
||||
if (!baseline) {
|
||||
clearPending()
|
||||
return
|
||||
}
|
||||
if (shouldIgnoreInterruptIntent(baseline.agentType, intent)) {
|
||||
clearPending()
|
||||
return
|
||||
}
|
||||
if (requiresDoubleEscapeForAgent(baseline.agentType, intent)) {
|
||||
const isSecondEscape =
|
||||
doubleEscapeBaseline !== null && isSameTurnBaseline(doubleEscapeBaseline, baseline)
|
||||
doubleEscapeBaseline = baseline
|
||||
clearPendingTimer()
|
||||
if (!isSecondEscape) {
|
||||
if (doubleEscapeTimer !== null) {
|
||||
clearTimer(doubleEscapeTimer)
|
||||
}
|
||||
// Why: some TUIs use the first Escape as an editor/menu cancel. Do
|
||||
// not let that arm a later single Escape indefinitely.
|
||||
doubleEscapeTimer = setTimer(clearDoubleEscapeBaseline, AGENT_INTERRUPT_SETTLE_MS)
|
||||
return
|
||||
}
|
||||
clearDoubleEscapeBaseline()
|
||||
// Why: these agents use the first Escape as a TUI/editor cancel. The
|
||||
// second Escape on the same turn is the actual running-turn interrupt.
|
||||
baseline = { ...baseline, inputCount: 2 }
|
||||
} else {
|
||||
clearDoubleEscapeBaseline()
|
||||
clearPendingTimer()
|
||||
}
|
||||
pendingBaseline = baseline
|
||||
if (shouldFlushInterruptImmediately(baseline)) {
|
||||
// Why: these agents can emit their idle/done hook immediately after an
|
||||
// accepted interrupt. Flush before that hook overwrites the working baseline.
|
||||
void flushPending()
|
||||
return
|
||||
}
|
||||
pendingTimer = setTimer(flushPendingFromTimer, AGENT_INTERRUPT_SETTLE_MS)
|
||||
},
|
||||
flushPending,
|
||||
dispose() {
|
||||
clearPending()
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,11 @@
|
||||
/* oxlint-disable max-lines */
|
||||
import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager'
|
||||
import type { IDisposable } from '@xterm/xterm'
|
||||
import { isGeminiTerminalTitle, isClaudeAgent } from '@/lib/agent-status'
|
||||
import {
|
||||
detectAgentStatusFromTitle,
|
||||
isGeminiTerminalTitle,
|
||||
isClaudeAgent
|
||||
} from '@/lib/agent-status'
|
||||
import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { PtyConnectResult } from './pty-transport'
|
||||
@@ -30,6 +34,15 @@ import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id'
|
||||
import {
|
||||
createAgentInterruptInference,
|
||||
isCtrlCKeyEvent,
|
||||
isPlainEscapeKeyEvent
|
||||
} from './agent-interrupt-inference'
|
||||
import {
|
||||
AGENT_INTERRUPT_SETTLE_MS,
|
||||
type AgentInterruptInputIntent
|
||||
} from '../../../../shared/agent-interrupt-intent'
|
||||
import { createAgentCompletionCoordinator } from './agent-completion-coordinator'
|
||||
|
||||
const pendingSpawnByPaneKey = new Map<string, Promise<string | null>>()
|
||||
@@ -193,19 +206,234 @@ 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 neutralTerminalTitle = (): string => {
|
||||
const state = useAppStore.getState()
|
||||
const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
|
||||
(entry) => entry.id === deps.tabId
|
||||
)
|
||||
return tab?.defaultTitle?.trim() || 'Terminal'
|
||||
}
|
||||
const clearInferredInterruptWorkingTitle = (): void => {
|
||||
const state = useAppStore.getState()
|
||||
const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
|
||||
const statusTitle = state.agentStatusByPaneKey[cacheKey]?.terminalTitle
|
||||
const title = currentTitle ?? statusTitle
|
||||
if (!title) {
|
||||
return
|
||||
}
|
||||
const neutralTitle = neutralTerminalTitle()
|
||||
// Why: inferred interrupts update the explicit hook row, but many CLIs leave
|
||||
// their OSC title stuck on a working spinner. Replace only this fallback
|
||||
// title signal with a neutral terminal label so the existing process tracker
|
||||
// can still decide whether an agent TUI is truly alive.
|
||||
deps.setRuntimePaneTitle(deps.tabId, pane.id, neutralTitle)
|
||||
if (manager.getActivePane()?.id === pane.id) {
|
||||
deps.updateTabTitle(deps.tabId, neutralTitle)
|
||||
}
|
||||
}
|
||||
let titleOnlyInterruptTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const clearTitleOnlyInterruptTimer = (): void => {
|
||||
if (titleOnlyInterruptTimer !== null) {
|
||||
clearTimeout(titleOnlyInterruptTimer)
|
||||
titleOnlyInterruptTimer = null
|
||||
}
|
||||
}
|
||||
const observeTitleOnlyInterrupt = (): void => {
|
||||
const state = useAppStore.getState()
|
||||
if (state.agentStatusByPaneKey[cacheKey]) {
|
||||
return
|
||||
}
|
||||
const runtimeTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
|
||||
const tabTitle = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
|
||||
(entry) => entry.id === deps.tabId
|
||||
)?.title
|
||||
const baselineTitle = runtimeTitle ?? tabTitle
|
||||
if (detectAgentStatusFromTitle(baselineTitle ?? '') !== 'working') {
|
||||
return
|
||||
}
|
||||
clearTitleOnlyInterruptTimer()
|
||||
titleOnlyInterruptTimer = setTimeout(() => {
|
||||
titleOnlyInterruptTimer = null
|
||||
if (useAppStore.getState().agentStatusByPaneKey[cacheKey]) {
|
||||
return
|
||||
}
|
||||
const currentState = useAppStore.getState()
|
||||
const currentRuntimeTitle = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
|
||||
const currentTabTitle = (currentState.tabsByWorktree[deps.worktreeId] ?? []).find(
|
||||
(entry) => entry.id === deps.tabId
|
||||
)?.title
|
||||
const currentTitle = currentRuntimeTitle ?? currentTabTitle
|
||||
if (
|
||||
currentTitle === baselineTitle &&
|
||||
detectAgentStatusFromTitle(currentTitle ?? '') === 'working'
|
||||
) {
|
||||
// Why: title-only agents such as Pi can miss their own idle title after
|
||||
// Ctrl+C. Clear only an unchanged, acknowledged working title.
|
||||
clearInferredInterruptWorkingTitle()
|
||||
}
|
||||
}, AGENT_INTERRUPT_SETTLE_MS)
|
||||
}
|
||||
const interruptInference = createAgentInterruptInference({
|
||||
paneKey: cacheKey,
|
||||
getStatusEntry: () => useAppStore.getState().agentStatusByPaneKey[cacheKey],
|
||||
inferInterrupt: (request) => {
|
||||
// Why: the explicit hook row is the authority for an in-flight agent turn.
|
||||
// Codex can reset its terminal title while handling Ctrl+C/Escape, so title
|
||||
// state must not veto clearing the row's working state.
|
||||
return window.api.agentStatus
|
||||
.inferInterrupt(request)
|
||||
.then((applied) => {
|
||||
if (applied) {
|
||||
clearInferredInterruptWorkingTitle()
|
||||
}
|
||||
return applied
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[agent-interrupt] inferInterrupt failed:', err)
|
||||
return false
|
||||
})
|
||||
}
|
||||
})
|
||||
const dropCommandFinishedStatusIfSameTurn = (
|
||||
entry: AgentStatusEntry | undefined,
|
||||
options?: { allowInferredInterrupt?: boolean }
|
||||
): void => {
|
||||
if (!entry) {
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const current = state.agentStatusByPaneKey[cacheKey]
|
||||
if (!current) {
|
||||
return
|
||||
}
|
||||
const unchanged =
|
||||
current.state === entry.state &&
|
||||
current.prompt === entry.prompt &&
|
||||
current.updatedAt === entry.updatedAt &&
|
||||
current.stateStartedAt === entry.stateStartedAt &&
|
||||
current.agentType === entry.agentType
|
||||
const inferredFromEntry =
|
||||
options?.allowInferredInterrupt === true &&
|
||||
current.state === 'done' &&
|
||||
current.interrupted === true &&
|
||||
current.prompt === entry.prompt &&
|
||||
current.agentType === entry.agentType &&
|
||||
current.stateHistory?.some(
|
||||
(history) =>
|
||||
history.state === entry.state &&
|
||||
history.prompt === entry.prompt &&
|
||||
history.startedAt === entry.stateStartedAt
|
||||
) === true
|
||||
if (!unchanged && !inferredFromEntry) {
|
||||
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 inferIntentFromExactTerminalInput = (data: string): AgentInterruptInputIntent | null => {
|
||||
if (data === '\x03') {
|
||||
return 'ctrl-c'
|
||||
}
|
||||
if (data === '\x1b') {
|
||||
return 'plain-escape'
|
||||
}
|
||||
return null
|
||||
}
|
||||
const observeSentTerminalInputIntent = (
|
||||
data: string,
|
||||
intent = pendingTerminalInputIntent
|
||||
): void => {
|
||||
if (intent && inputMatchesIntent(intent, data)) {
|
||||
interruptInference.observeInputIntent(intent)
|
||||
observeTitleOnlyInterrupt()
|
||||
}
|
||||
}
|
||||
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) {
|
||||
// Why: OSC 133 D means the foreground shell command exited. If an
|
||||
// interrupt was inferred first, drop only when the current interrupted
|
||||
// row is still the same turn; otherwise a killed OpenCode CLI leaves a
|
||||
// stale "interrupted" row even though the process is gone.
|
||||
dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true })
|
||||
return
|
||||
}
|
||||
if (inferenceResult instanceof Promise) {
|
||||
void inferenceResult.then((applied) => {
|
||||
dropCommandFinishedStatusIfSameTurn(entry, {
|
||||
allowInferredInterrupt: applied === true
|
||||
})
|
||||
})
|
||||
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.
|
||||
if (entry) {
|
||||
state.dropAgentStatus(cacheKey)
|
||||
}
|
||||
dropCommandFinishedStatusIfSameTurn(entry)
|
||||
}
|
||||
})
|
||||
commandLifecycle.attachXtermConsumer(pane.terminal)
|
||||
const onTerminalKeyDown = (event: KeyboardEvent): void => {
|
||||
if (isPlainEscapeKeyEvent(event)) {
|
||||
setPendingTerminalInputIntent('plain-escape')
|
||||
return
|
||||
}
|
||||
if (isCtrlCKeyEvent(event)) {
|
||||
if (!navigator.userAgent.includes('Mac') && pane.terminal.hasSelection()) {
|
||||
return
|
||||
}
|
||||
setPendingTerminalInputIntent('ctrl-c')
|
||||
}
|
||||
}
|
||||
// Why: infer only from focused xterm key events. Raw PTY bytes cannot
|
||||
// distinguish plain Escape from Alt/meta sequences, and programmatic writes
|
||||
// should not clear agent status.
|
||||
const terminalKeyTarget = pane.terminal.element ?? pane.container
|
||||
const terminalKeyTargetSupportsEvents =
|
||||
typeof terminalKeyTarget?.addEventListener === 'function' &&
|
||||
typeof terminalKeyTarget?.removeEventListener === 'function'
|
||||
if (terminalKeyTargetSupportsEvents) {
|
||||
terminalKeyTarget.addEventListener('keydown', onTerminalKeyDown, { capture: true })
|
||||
}
|
||||
|
||||
const agentCompletionCoordinator = createAgentCompletionCoordinator({
|
||||
paneKey: cacheKey,
|
||||
@@ -511,10 +739,9 @@ export function connectPanePty(
|
||||
// the agent has exited. Clear any running cache timer so the sidebar doesn't
|
||||
// show a stale countdown for a tab that no longer has an active Claude session.
|
||||
deps.setCacheTimerStartedAt(cacheKey, null)
|
||||
// Why: do not let terminal-title reversion own agent-status lifecycle.
|
||||
// Explicit hooks and OSC 133 command-finished marks are the reliable
|
||||
// signals; title changes can race normal "done" states and make agents
|
||||
// look like they vanished as soon as they finished responding.
|
||||
clearTitleOnlyInterruptTimer()
|
||||
// Why: title reversion alone is not process death. The process/PTY tracker
|
||||
// owns removing agent rows when the TUI actually exits.
|
||||
}
|
||||
// Why: inject ORCA_PANE_KEY so global Claude/Codex hooks can attribute their
|
||||
// callbacks to the correct Orca pane without resolving worktrees from cwd.
|
||||
@@ -611,6 +838,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
|
||||
@@ -621,6 +849,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
|
||||
@@ -629,7 +858,38 @@ export function connectPanePty(
|
||||
// auto-replies never count as interaction.
|
||||
deps.clearTerminalTabUnread(deps.tabId)
|
||||
deps.clearWorktreeUnread(deps.worktreeId)
|
||||
transport.sendInput(data)
|
||||
const intent = pendingTerminalInputIntent
|
||||
// Why: real xterm can deliver the terminal byte even when our DOM keydown
|
||||
// listener missed the press. Exact Ctrl+C/Escape bytes are still safe to
|
||||
// infer for local/remote acknowledged writes; SSH fire-and-forget remains
|
||||
// excluded because those transports do not expose sendInputAccepted.
|
||||
const acknowledgedIntent = intent ?? inferIntentFromExactTerminalInput(data)
|
||||
if (acknowledgedIntent && transport.sendInputAccepted) {
|
||||
clearPendingTerminalInputIntent()
|
||||
const writePromise = transport
|
||||
.sendInputAccepted(data)
|
||||
.then((accepted) => {
|
||||
if (accepted) {
|
||||
interruptInference.observeInputIntent(acknowledgedIntent)
|
||||
observeTitleOnlyInterrupt()
|
||||
}
|
||||
})
|
||||
.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 }) => {
|
||||
@@ -1484,6 +1744,13 @@ export function connectPanePty(
|
||||
return {
|
||||
dispose() {
|
||||
disposed = true
|
||||
if (terminalKeyTargetSupportsEvents) {
|
||||
terminalKeyTarget.removeEventListener('keydown', onTerminalKeyDown, { capture: true })
|
||||
}
|
||||
clearPendingTerminalInputIntent()
|
||||
pendingTerminalInputWrite = null
|
||||
interruptInference.dispose()
|
||||
clearTitleOnlyInterruptTimer()
|
||||
// Why: actively resolve any in-flight passphrase-gate waits so their
|
||||
// zustand subscribers + async IIFEs don't hang for the rest of the
|
||||
// session when the pane is torn down before SSH state changes.
|
||||
|
||||
@@ -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('preserves literal LF input when sending remote PTY binary frames', 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'
|
||||
@@ -206,6 +207,28 @@ export function createRemoteRuntimePtyTransport(
|
||||
}
|
||||
}
|
||||
|
||||
async function sendInputAcceptedToRuntime(data: string): Promise<boolean> {
|
||||
const targetHandle = handle
|
||||
if (!connected || !targetHandle) {
|
||||
return false
|
||||
}
|
||||
if (!data) {
|
||||
return true
|
||||
}
|
||||
const text = `${inputBatcher.takePending()}${data}`
|
||||
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) {
|
||||
@@ -458,6 +481,8 @@ export function createRemoteRuntimePtyTransport(
|
||||
return true
|
||||
},
|
||||
|
||||
sendInputAccepted: sendInputAcceptedToRuntime,
|
||||
|
||||
resize(cols: number, rows: number): boolean {
|
||||
if (!connected || !handle) {
|
||||
return false
|
||||
|
||||
@@ -191,6 +191,7 @@ function createWebPreloadApi(): Partial<PreloadApi> {
|
||||
agentStatus: {
|
||||
onSet: () => noopUnsubscribe,
|
||||
getSnapshot: () => Promise.resolve([]),
|
||||
inferInterrupt: () => Promise.resolve(false),
|
||||
onMigrationUnsupported: () => noopUnsubscribe,
|
||||
onMigrationUnsupportedClear: () => noopUnsubscribe,
|
||||
getMigrationUnsupportedSnapshot: () => Promise.resolve([]),
|
||||
@@ -1080,6 +1081,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
|
||||
}
|
||||
|
||||
@@ -1523,19 +1528,25 @@ function normalizeCursorEvent(
|
||||
paneKey: string,
|
||||
hookPayload: Record<string, unknown>
|
||||
): ParsedAgentStatusPayload | null {
|
||||
// Why: Cursor can emit the final response text after `stop`; that should
|
||||
// enrich the completed row, not resurrect the agent as working.
|
||||
const previousStatus = state.lastStatusByPaneKey.get(paneKey)?.payload
|
||||
const stateName =
|
||||
eventName === 'beforeSubmitPrompt' ||
|
||||
eventName === 'sessionStart' ||
|
||||
eventName === 'preToolUse' ||
|
||||
eventName === 'postToolUse' ||
|
||||
eventName === 'postToolUseFailure' ||
|
||||
eventName === 'afterAgentResponse'
|
||||
eventName === 'postToolUseFailure'
|
||||
? 'working'
|
||||
: eventName === 'stop' || eventName === 'sessionEnd'
|
||||
? 'done'
|
||||
: eventName === 'beforeShellExecution' || eventName === 'beforeMCPExecution'
|
||||
? 'waiting'
|
||||
: null
|
||||
: eventName === 'afterAgentResponse'
|
||||
? previousStatus?.state === 'done' && previousStatus.agentType === 'cursor'
|
||||
? 'done'
|
||||
: 'working'
|
||||
: eventName === 'stop' || eventName === 'sessionEnd'
|
||||
? 'done'
|
||||
: eventName === 'beforeShellExecution' || eventName === 'beforeMCPExecution'
|
||||
? 'waiting'
|
||||
: null
|
||||
|
||||
if (!stateName) {
|
||||
return null
|
||||
@@ -1973,7 +1984,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
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { AgentType } from './agent-status-types'
|
||||
|
||||
export type AgentInterruptInputIntent = 'plain-escape' | 'ctrl-c'
|
||||
|
||||
export const AGENT_INTERRUPT_SETTLE_MS = 500
|
||||
|
||||
export type AgentInterruptInferenceRequest = {
|
||||
paneKey: string
|
||||
baselineUpdatedAt: number
|
||||
baselineStateStartedAt: number
|
||||
baselinePrompt: string
|
||||
baselineAgentType: AgentType | undefined
|
||||
intent: AgentInterruptInputIntent
|
||||
inputCount?: number
|
||||
}
|
||||
|
||||
export function isAgentInterruptInputIntent(intent: unknown): intent is AgentInterruptInputIntent {
|
||||
return intent === 'plain-escape' || intent === 'ctrl-c'
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
// ─── Explicit agent status (reported via native agent hooks → IPC) ──────────
|
||||
// These types define the normalized status that Orca receives from Claude,
|
||||
// Codex, and other explicit integrations. Agent state is hook-reported only —
|
||||
// we do not infer status from terminal titles anywhere in the data flow.
|
||||
// Codex, and other explicit integrations. Agent state normally comes from
|
||||
// hooks; a narrow interrupt fallback may synthesize a final done state when an
|
||||
// agent misses its own cancellation hook. We still do not infer status from
|
||||
// terminal titles anywhere in the data flow.
|
||||
|
||||
export const AGENT_STATUS_STATES = ['working', 'blocked', 'waiting', 'done'] as const
|
||||
export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number]
|
||||
@@ -39,10 +41,10 @@ export type AgentStateHistoryEntry = {
|
||||
prompt: string
|
||||
/** When this state was first reported. */
|
||||
startedAt: number
|
||||
/** True when this `done` was a cancellation (user hit ESC/Ctrl+C). Reported
|
||||
* by the agent itself — Claude Code sets `is_interrupt: true` on its `Stop`
|
||||
* hook when the turn ended via interrupt. Always falsy for non-`done`
|
||||
* states, so retention logic can preserve this signal. */
|
||||
/** True when this `done` was a cancellation. May come from an agent hook
|
||||
* (for example Claude Code `is_interrupt`) or Orca's guarded interrupt
|
||||
* fallback. Always falsy for non-`done` states, so retention logic can
|
||||
* preserve this signal. */
|
||||
interrupted?: boolean
|
||||
}
|
||||
|
||||
@@ -77,10 +79,11 @@ export type AgentStatusEntry = {
|
||||
/** Most recent assistant message preview, when the hook carried one. */
|
||||
lastAssistantMessage?: string
|
||||
/** True when the current `done` state was reached via an interrupt rather
|
||||
* than a normal turn completion (Claude Code's `is_interrupt: true`).
|
||||
* than a normal turn completion. May be reported by the agent itself or
|
||||
* inferred by Orca's guarded interrupt fallback.
|
||||
* Orthogonal to `state`: the agent still finished the turn, but the user
|
||||
* cancelled it. Undefined while the agent is working or for non-Claude
|
||||
* agents that don't surface this signal. */
|
||||
* cancelled it. Undefined while the agent is working or when no interrupt
|
||||
* signal was available. */
|
||||
interrupted?: boolean
|
||||
}
|
||||
|
||||
|
||||
+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