mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
485 lines
17 KiB
TypeScript
485 lines
17 KiB
TypeScript
import { readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { OPENCODE_AGENT_ROW_GRACE_MS } from '../../shared/opencode-agent-row-scanner'
|
|
import {
|
|
deliverStartupFollowupPrompt,
|
|
waitForWorktreeStartupDraft,
|
|
type WorktreeStartupReadinessHost
|
|
} from './runtime-worktree-startup-readiness'
|
|
|
|
describe('startup follow-up prompt', () => {
|
|
afterEach(() => vi.useRealTimers())
|
|
|
|
function fixture(
|
|
foreground: string,
|
|
hostProvesAgent: boolean,
|
|
hostProvesShellAlone = !hostProvesAgent,
|
|
launchedForeground: 'agent' | 'unknown' = 'agent'
|
|
) {
|
|
const write = vi.fn()
|
|
const host: WorktreeStartupReadinessHost = {
|
|
getPtyId: () => 'pty-1',
|
|
getForegroundProcess: async () => foreground,
|
|
hasChildProcesses: async () => false,
|
|
subscribeToData: () => () => {},
|
|
readRecentOutput: () => undefined,
|
|
write
|
|
}
|
|
const sendTerminalAgentPrompt = vi.fn(async (handle: string) => ({
|
|
handle,
|
|
accepted: true,
|
|
bytesWritten: 12
|
|
}))
|
|
const waitForFreshWorkerComposer = vi.fn(async () => ({ satisfied: true, status: 'running' }))
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: delivery reaches only these runtime methods; any other would throw.
|
|
const runtime = {
|
|
waitForFreshWorkerComposer,
|
|
waitForTerminal: vi.fn(async () => ({ satisfied: true, status: 'idle' })),
|
|
sendTerminalAgentPrompt,
|
|
readLaunchedAgentForeground: vi.fn(async () => launchedForeground),
|
|
subscribeToTerminalData: vi.fn(() => () => {}),
|
|
launchedAgentHostProvesAgent: vi.fn(() => hostProvesAgent),
|
|
launchedAgentHostProvesShellAlone: vi.fn(() => hostProvesShellAlone)
|
|
} as unknown as Parameters<typeof deliverStartupFollowupPrompt>[0]
|
|
return { host, write, runtime, sendTerminalAgentPrompt, waitForFreshWorkerComposer }
|
|
}
|
|
|
|
// Kimi is in front before its TUI reads input; text typed then is echoed and lost (#10336).
|
|
it('pastes and submits through composer readiness, never typing raw input', async () => {
|
|
const f = fixture('kimi', true)
|
|
|
|
await expect(
|
|
deliverStartupFollowupPrompt(f.runtime, f.host, 'term-1', {
|
|
agent: 'kimi',
|
|
prompt: 'fix the flaky test'
|
|
})
|
|
).resolves.toBe(true)
|
|
expect(f.waitForFreshWorkerComposer).toHaveBeenCalled()
|
|
expect(f.sendTerminalAgentPrompt).toHaveBeenCalledWith(
|
|
'term-1',
|
|
'fix the flaky test',
|
|
expect.objectContaining({ inputKind: 'launch', composerReady: true })
|
|
)
|
|
expect(f.write).not.toHaveBeenCalled()
|
|
})
|
|
|
|
// WSL and SSH-to-Windows reads cannot prove a lone shell, so they keep main's behaviour per caller.
|
|
it('types the follow-up on WSL or SSH-to-Windows once the agent is in front, as before', async () => {
|
|
const f = fixture('kimi', false, false, 'unknown')
|
|
|
|
await expect(
|
|
deliverStartupFollowupPrompt(f.runtime, f.host, 'term-1', { agent: 'kimi', prompt: 'go' })
|
|
).resolves.toBe(true)
|
|
expect(f.write).toHaveBeenCalledWith('pty-1', 'go\r', 'launch')
|
|
expect(f.sendTerminalAgentPrompt).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('still refuses an agent.launch prompt on WSL or SSH-to-Windows, as before', async () => {
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
const f = fixture('kimi', false, false, 'unknown')
|
|
f.sendTerminalAgentPrompt.mockImplementation(async (handle: string, ...rest: unknown[]) => {
|
|
const options = rest[1]
|
|
if (options && typeof options === 'object' && 'beforeWrite' in options) {
|
|
const { beforeWrite } = options
|
|
if (typeof beforeWrite === 'function') {
|
|
await beforeWrite('pty-1')
|
|
}
|
|
}
|
|
return { handle, accepted: true, bytesWritten: 2 }
|
|
})
|
|
|
|
await expect(
|
|
deliverStartupFollowupPrompt(
|
|
f.runtime,
|
|
f.host,
|
|
'term-1',
|
|
{ agent: 'kimi', prompt: 'go' },
|
|
{ agentLaunch: true }
|
|
)
|
|
).resolves.toBe(false)
|
|
expect(f.write).not.toHaveBeenCalled()
|
|
warn.mockRestore()
|
|
})
|
|
|
|
it('writes nothing on a Windows host while the shell stays in front', async () => {
|
|
vi.useFakeTimers()
|
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
const f = fixture('pwsh', false)
|
|
|
|
const delivered = deliverStartupFollowupPrompt(f.runtime, f.host, 'term-1', {
|
|
agent: 'kimi',
|
|
prompt: 'fix the flaky test'
|
|
})
|
|
await vi.advanceTimersByTimeAsync(30 * 150)
|
|
|
|
await expect(delivered).resolves.toBe(false)
|
|
expect(f.sendTerminalAgentPrompt).not.toHaveBeenCalled()
|
|
expect(f.write).not.toHaveBeenCalled()
|
|
warn.mockRestore()
|
|
})
|
|
})
|
|
|
|
describe('fresh worker composer readiness', () => {
|
|
afterEach(() => vi.useRealTimers())
|
|
|
|
function fixture(replay?: string) {
|
|
let listener = (_data: string): void => {}
|
|
const unsubscribe = vi.fn()
|
|
const host: WorktreeStartupReadinessHost = {
|
|
getPtyId: () => 'pty-1',
|
|
getForegroundProcess: async () => 'zcode',
|
|
subscribeToData: (_ptyId, onData) => {
|
|
listener = onData
|
|
return unsubscribe
|
|
},
|
|
readRecentOutput: () => replay,
|
|
write: vi.fn()
|
|
}
|
|
return { host, emit: (data: string) => listener(data), unsubscribe }
|
|
}
|
|
|
|
it('accepts the captured composer while the banner continues repainting', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture()
|
|
const pending = waitForWorktreeStartupDraft(h.host, 'term-1', 'zcode', {
|
|
timeoutMs: 45_000,
|
|
requireComposerMarker: true
|
|
})
|
|
const data = readFileSync(join(__dirname, '__fixtures__', 'zcode-composer-ready.txt'), 'utf8')
|
|
for (let offset = 0; offset < data.length; offset += 4096) {
|
|
h.emit(data.slice(offset, offset + 4096))
|
|
}
|
|
await expect(pending).resolves.toBe('pty-1')
|
|
expect(h.unsubscribe).toHaveBeenCalledOnce()
|
|
expect(h.host.write).not.toHaveBeenCalled()
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
})
|
|
|
|
it('cleans up the deadline when the composer was already captured', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture('\x1b[?1049h╭')
|
|
await expect(
|
|
waitForWorktreeStartupDraft(h.host, 'term-1', 'zcode', {
|
|
timeoutMs: 45_000,
|
|
requireComposerMarker: true
|
|
})
|
|
).resolves.toBe('pty-1')
|
|
expect(h.unsubscribe).toHaveBeenCalledOnce()
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
})
|
|
|
|
it('does not accept shell decoration or a square startup dialog', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture('╭ shell\n\x1b[?1049h\x1b[?2004h┌ Sign in ┐')
|
|
const pending = waitForWorktreeStartupDraft(h.host, 'term-1', 'zcode', {
|
|
timeoutMs: 45_000,
|
|
requireComposerMarker: true
|
|
})
|
|
await vi.advanceTimersByTimeAsync(44_999)
|
|
expect(h.unsubscribe).not.toHaveBeenCalled()
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
await expect(pending).resolves.toBeNull()
|
|
expect(h.unsubscribe).toHaveBeenCalledOnce()
|
|
})
|
|
})
|
|
|
|
describe('OpenCode submit readiness', () => {
|
|
afterEach(() => vi.useRealTimers())
|
|
|
|
// OpenCode 2.0.21's box with its cursor and bottom corner, then the agent row directly above it.
|
|
const BOX = '\x1b[?1049h\x1b[?2004h\x1b[24;24H┃\x1b[25;24H╹\x1b[22;27H\x1b[?25h'
|
|
const AGENT_ROW = '\x1b[24;27HBuild\x1b[24;33H\u00b7\x1b[24;35HSome Model'
|
|
|
|
function fixture() {
|
|
let listener = (_data: string): void => {}
|
|
const host: WorktreeStartupReadinessHost = {
|
|
getPtyId: () => 'pty-1',
|
|
getForegroundProcess: async () => 'opencode',
|
|
subscribeToData: (_ptyId, onData) => {
|
|
listener = onData
|
|
return () => {}
|
|
},
|
|
readRecentOutput: () => undefined,
|
|
write: vi.fn()
|
|
}
|
|
return { host, emit: (data: string) => listener(data) }
|
|
}
|
|
|
|
function settle(pending: Promise<string | null>): { value: string | null | undefined } {
|
|
const result: { value: string | null | undefined } = { value: undefined }
|
|
void pending.then((value) => (result.value = value))
|
|
return result
|
|
}
|
|
|
|
it('a draft that is not submitted is pasted on the box cursor, as before', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture()
|
|
const result = settle(waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2'))
|
|
h.emit(BOX)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(result.value).toBe('pty-1')
|
|
})
|
|
|
|
it('a submitted task waits for the agent row', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture()
|
|
const result = settle(
|
|
waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', {
|
|
timeoutMs: 60_000,
|
|
requireComposerMarker: true,
|
|
submit: true
|
|
})
|
|
)
|
|
h.emit(BOX)
|
|
await vi.advanceTimersByTimeAsync(1000)
|
|
expect(result.value).toBeUndefined()
|
|
h.emit(AGENT_ROW)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(result.value).toBe('pty-1')
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
})
|
|
|
|
it('takes the box after the grace when the row never comes, whatever repaints', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture()
|
|
const result = settle(
|
|
waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', {
|
|
timeoutMs: 60_000,
|
|
requireComposerMarker: true,
|
|
submit: true
|
|
})
|
|
)
|
|
h.emit(BOX)
|
|
await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS - 1)
|
|
h.emit('\x1b[22;27H\x1b[?25h')
|
|
expect(result.value).toBeUndefined()
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(result.value).toBe('pty-1')
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
})
|
|
|
|
it('takes the box at the deadline when the grace would outlast the budget', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture()
|
|
const result = settle(
|
|
waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', {
|
|
timeoutMs: 60_000,
|
|
requireComposerMarker: true,
|
|
submit: true
|
|
})
|
|
)
|
|
await vi.advanceTimersByTimeAsync(59_000)
|
|
h.emit(BOX)
|
|
await vi.advanceTimersByTimeAsync(999)
|
|
expect(result.value).toBeUndefined()
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(result.value).toBe('pty-1')
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
})
|
|
|
|
it('withdraws the grace when OpenCode turns bracketed paste off', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture()
|
|
const result = settle(
|
|
waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', {
|
|
timeoutMs: 60_000,
|
|
requireComposerMarker: true,
|
|
submit: true
|
|
})
|
|
)
|
|
h.emit(BOX)
|
|
h.emit('\x1b[?2004l\x1b[?1049l')
|
|
await vi.advanceTimersByTimeAsync(59_999)
|
|
expect(result.value).toBeUndefined()
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(result.value).toBeNull()
|
|
})
|
|
|
|
describe('through the settle checks a ready signal gets', () => {
|
|
function waitWith(
|
|
h: ReturnType<typeof fixture>,
|
|
checks: {
|
|
accept?: () => boolean
|
|
isShellInFront?: () => Promise<boolean>
|
|
timeoutMs?: number
|
|
signal?: AbortSignal
|
|
}
|
|
) {
|
|
return settle(
|
|
waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', {
|
|
timeoutMs: checks.timeoutMs ?? 60_000,
|
|
requireComposerMarker: true,
|
|
submit: true,
|
|
...(checks.accept ? { accept: checks.accept } : {}),
|
|
...(checks.isShellInFront ? { isShellInFront: checks.isShellInFront } : {}),
|
|
...(checks.signal ? { signal: checks.signal } : {})
|
|
})
|
|
)
|
|
}
|
|
|
|
it('does not settle a grace while a shell is proven in front', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture()
|
|
let shellInFront = true
|
|
const result = waitWith(h, { isShellInFront: async () => shellInFront })
|
|
h.emit(BOX)
|
|
await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS)
|
|
expect(result.value).toBeUndefined()
|
|
// The next read that still shows the box asks again, and now OpenCode is in front.
|
|
shellInFront = false
|
|
h.emit('\x1b[22;27H\x1b[?25h')
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(result.value).toBe('pty-1')
|
|
})
|
|
|
|
it('lets a dialog on screen veto a grace', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture()
|
|
const accept = vi.fn(() => false)
|
|
const result = waitWith(h, { accept })
|
|
h.emit(BOX)
|
|
await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS)
|
|
expect(accept).toHaveBeenCalledWith('pty-1')
|
|
expect(result.value).toBeUndefined()
|
|
})
|
|
|
|
it('refuses the deadline inside a pending grace while a shell is in front', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture()
|
|
const result = waitWith(h, { isShellInFront: async () => true, timeoutMs: 3000 })
|
|
h.emit(BOX)
|
|
await vi.advanceTimersByTimeAsync(3000)
|
|
expect(result.value).toBeNull()
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
})
|
|
|
|
it('takes the box at the deadline inside a pending grace with the agent in front', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture()
|
|
const result = waitWith(h, { isShellInFront: async () => false, timeoutMs: 3000 })
|
|
h.emit(BOX)
|
|
await vi.advanceTimersByTimeAsync(3000)
|
|
expect(result.value).toBe('pty-1')
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
})
|
|
|
|
it('withdraws a pending grace at a shell hand-off', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture()
|
|
const result = waitWith(h, { isShellInFront: async () => false, timeoutMs: 8000 })
|
|
h.emit(BOX)
|
|
// The agent exits to its shell, which turns bracketed paste off as it runs a command.
|
|
h.emit('\x1b[?2004l')
|
|
await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS)
|
|
expect(result.value).toBeUndefined()
|
|
await vi.advanceTimersByTimeAsync(8000 - OPENCODE_AGENT_ROW_GRACE_MS)
|
|
expect(result.value).toBeNull()
|
|
})
|
|
|
|
// A foreground read the test answers by hand, as an SSH host would.
|
|
function pendingRead() {
|
|
let answer = (_inFront: boolean): void => {}
|
|
let fail = (_error: Error): void => {}
|
|
const read = vi.fn(
|
|
() =>
|
|
new Promise<boolean>((resolve, reject) => {
|
|
answer = resolve
|
|
fail = reject
|
|
})
|
|
)
|
|
return { read, answer: (inFront: boolean) => answer(inFront), fail: (e: Error) => fail(e) }
|
|
}
|
|
|
|
it('refuses the deadline when a shell hand-off lands during its check', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture()
|
|
const check = pendingRead()
|
|
const result = waitWith(h, { isShellInFront: check.read, timeoutMs: 3000 })
|
|
h.emit(BOX)
|
|
await vi.advanceTimersByTimeAsync(3000)
|
|
h.emit('\x1b[?2004l')
|
|
check.answer(false)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(result.value).toBeNull()
|
|
})
|
|
|
|
it('refuses the deadline when its check fails', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture()
|
|
const check = pendingRead()
|
|
const result = waitWith(h, { isShellInFront: check.read, timeoutMs: 3000 })
|
|
h.emit(BOX)
|
|
await vi.advanceTimersByTimeAsync(3000)
|
|
check.fail(new Error('ssh channel closed'))
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(result.value).toBeNull()
|
|
})
|
|
|
|
it('resolves once, with null, when aborted during the deadline check', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture()
|
|
const check = pendingRead()
|
|
const abort = new AbortController()
|
|
const pending = waitForWorktreeStartupDraft(h.host, 'term-1', 'opencode2', {
|
|
timeoutMs: 3000,
|
|
requireComposerMarker: true,
|
|
submit: true,
|
|
isShellInFront: check.read,
|
|
signal: abort.signal
|
|
})
|
|
const resolved = vi.fn()
|
|
void pending.then(resolved)
|
|
h.emit(BOX)
|
|
await vi.advanceTimersByTimeAsync(3000)
|
|
abort.abort()
|
|
check.answer(false)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(resolved.mock.calls).toEqual([[null]])
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
})
|
|
|
|
it('takes the box at the deadline after a refused grace once the agent is in front', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture()
|
|
let shellInFront = true
|
|
const result = waitWith(h, { isShellInFront: async () => shellInFront, timeoutMs: 8000 })
|
|
h.emit(BOX)
|
|
await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS)
|
|
expect(result.value).toBeUndefined()
|
|
shellInFront = false
|
|
await vi.advanceTimersByTimeAsync(8000 - OPENCODE_AGENT_ROW_GRACE_MS)
|
|
expect(result.value).toBe('pty-1')
|
|
})
|
|
|
|
it('gives up when the deadline check never answers', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture()
|
|
const check = pendingRead()
|
|
const result = waitWith(h, { isShellInFront: check.read, timeoutMs: 3000 })
|
|
h.emit(BOX)
|
|
await vi.advanceTimersByTimeAsync(3000)
|
|
expect(check.read).toHaveBeenCalled()
|
|
await vi.advanceTimersByTimeAsync(1999)
|
|
expect(result.value).toBeUndefined()
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(result.value).toBeNull()
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
})
|
|
|
|
it('restarts the grace at a hand-off rather than keeping the one from before it', async () => {
|
|
vi.useFakeTimers()
|
|
const h = fixture()
|
|
const result = waitWith(h, { isShellInFront: async () => false })
|
|
h.emit(BOX)
|
|
await vi.advanceTimersByTimeAsync(3000)
|
|
// A hand-off whose chunk goes on to show a cursor under bracketed paste again.
|
|
h.emit('\x1b[?2004l\x1b[?2004h\x1b[?25h')
|
|
await vi.advanceTimersByTimeAsync(OPENCODE_AGENT_ROW_GRACE_MS - 1)
|
|
expect(result.value).toBeUndefined()
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(result.value).toBe('pty-1')
|
|
})
|
|
})
|
|
})
|