test(ci): stabilize cancellation and crash checkpoints
This commit is contained in:
@@ -16,18 +16,20 @@ const childScript = fileURLToPath(new URL('./fixtures/crash-child.ts', import.me
|
||||
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
|
||||
const sessionId = SessionId('semantic-checkpoint-crash')
|
||||
const roots: string[] = []
|
||||
const CHILD_FAILPOINT_TIMEOUT_MS = 30_000
|
||||
|
||||
async function waitForFile(path: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||
const deadline = Date.now() + CHILD_FAILPOINT_TIMEOUT_MS
|
||||
for (;;) {
|
||||
try {
|
||||
await access(path)
|
||||
return
|
||||
} catch (error: unknown) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
if (Date.now() >= deadline) throw new Error(`crash child did not reach failpoint ${path}`)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
throw new Error(`crash child did not reach failpoint ${path}`)
|
||||
}
|
||||
|
||||
async function crashAt(mode: 'request' | 'tool'): Promise<{ root: string; markerText: string }> {
|
||||
|
||||
@@ -35,8 +35,8 @@ import { launchAcpTestAgent, type AgentUnderTest, type LaunchedAcpTestAgent } fr
|
||||
|
||||
export type { AgentUnderTest } from './launcher.ts'
|
||||
|
||||
const DEFAULT_TURN_END_TIMEOUT_MS = 10_000
|
||||
const TURN_END_POLL_INTERVAL_MS = 10
|
||||
const DEFAULT_WAIT_TIMEOUT_MS = 10_000
|
||||
const WAIT_POLL_INTERVAL_MS = 10
|
||||
|
||||
/**
|
||||
* One step of a scenario's deterministic input script (`input.json`). The
|
||||
@@ -46,8 +46,9 @@ const TURN_END_POLL_INTERVAL_MS = 10
|
||||
*
|
||||
* `promptAndCancel` starts a prompt without awaiting completion, waits until
|
||||
* the client observes the selected update (`agent_message_chunk` by default),
|
||||
* then cancels and awaits completion. A named `waitForToolCallUpdate` keeps the
|
||||
* step open for a terminal tool update that may follow the prompt response.
|
||||
* then cancels and awaits completion. An optional `waitForFile` first observes
|
||||
* a cwd-relative readiness marker, and a named `waitForToolCallUpdate` keeps
|
||||
* the step open for a terminal tool update that may follow the prompt response.
|
||||
* `promptAndWaitForAgentMessage` arms an exact text-chunk waiter before sending
|
||||
* the prompt, then keeps the application live until that later update arrives.
|
||||
* `waitForTurnEnd` holds the subprocess open until the selected session's latest
|
||||
@@ -64,6 +65,7 @@ export type InputStep =
|
||||
op: 'promptAndCancel'
|
||||
text: string
|
||||
afterUpdate?: 'agent_message_chunk' | 'tool_call'
|
||||
waitForFile?: { path: string; timeoutMs?: number }
|
||||
waitForToolCallUpdate?: string
|
||||
}
|
||||
| { op: 'waitForTurnEnd'; timeoutMs?: number }
|
||||
@@ -445,6 +447,9 @@ async function runStep(
|
||||
const promptDone = client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] })
|
||||
const afterUpdate = step.afterUpdate ?? 'agent_message_chunk'
|
||||
await waitForUpdate(u => u.sessionUpdate === afterUpdate)
|
||||
if (step.waitForFile !== undefined) {
|
||||
await waitForWorkspaceFile(cwd, step.waitForFile.path, step.waitForFile.timeoutMs)
|
||||
}
|
||||
// Arm this before cancellation so a fast tool drain cannot outrun the waiter.
|
||||
const toolCallUpdateDone = step.waitForToolCallUpdate === undefined
|
||||
? undefined
|
||||
@@ -516,7 +521,7 @@ async function runStep(
|
||||
async function waitForPersistedTurnEnd(
|
||||
root: string,
|
||||
sessionId: string,
|
||||
timeoutMs = DEFAULT_TURN_END_TIMEOUT_MS,
|
||||
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (true) {
|
||||
@@ -525,7 +530,23 @@ async function waitForPersistedTurnEnd(
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`snapshot-harness: session "${sessionId}" did not persist turn/end within ${timeoutMs}ms`)
|
||||
}
|
||||
await delay(TURN_END_POLL_INTERVAL_MS)
|
||||
await delay(WAIT_POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait for a cwd-relative marker proving an external action reached readiness. */
|
||||
async function waitForWorkspaceFile(
|
||||
cwd: string,
|
||||
path: string,
|
||||
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
const target = join(cwd, path)
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (!existsSync(target)) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`snapshot-harness: workspace file "${path}" did not appear within ${timeoutMs}ms`)
|
||||
}
|
||||
await delay(WAIT_POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -493,6 +493,37 @@ describe('runScenario', () => {
|
||||
expect(result.rawStdout.indexOf('thinking about it')).toBeLessThan(result.rawStdout.indexOf('cancelled'))
|
||||
})
|
||||
|
||||
it('promptAndCancel can wait for cwd-relative readiness before cancelling', { timeout: 20_000 }, async () => {
|
||||
const { dir, fixtureFile } = await scenario({ prompt: 'hang-until-cancel' })
|
||||
const workspaceDir = join(dir, 'workspace')
|
||||
const { mkdir } = await import('node:fs/promises')
|
||||
await mkdir(workspaceDir, { recursive: true })
|
||||
await writeFile(join(workspaceDir, 'started.txt'), 'started')
|
||||
const result = await runScenario(
|
||||
{
|
||||
steps: [...boot, {
|
||||
op: 'promptAndCancel',
|
||||
text: 'hang',
|
||||
waitForFile: { path: 'started.txt' },
|
||||
}],
|
||||
},
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile, workspaceDir },
|
||||
)
|
||||
expect(result.rawStdout).toContain('"stopReason":"cancelled"')
|
||||
|
||||
const missing = await scenario({ prompt: 'hang-until-cancel' })
|
||||
await expect(runScenario(
|
||||
{
|
||||
steps: [...boot, {
|
||||
op: 'promptAndCancel',
|
||||
text: 'hang',
|
||||
waitForFile: { path: 'never.txt', timeoutMs: 20 },
|
||||
}],
|
||||
},
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile: missing.fixtureFile },
|
||||
)).rejects.toThrow(/workspace file "never\.txt" did not appear within 20ms/)
|
||||
})
|
||||
|
||||
it('promptAndWaitForAgentMessage keeps the app live through a matching later update', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({ prompt: 'respond' })
|
||||
const result = await runScenario(
|
||||
|
||||
Reference in New Issue
Block a user