fix inbox lifecycle downstream contracts
This commit is contained in:
@@ -50,6 +50,7 @@ const WAIT_POLL_INTERVAL_MS = 10
|
||||
* `waitForTurnStart` waits for an open durable turn, optionally at or beyond a
|
||||
* specified turn number. `waitForTurnEnd` holds the subprocess open until the
|
||||
* selected session's latest complete raw-JSONL turn boundary is `turn/end`.
|
||||
* `waitForInboxMessage` waits for inserted inbox text containing a scenario marker.
|
||||
* `waitForTitleAfterTurnEnd` additionally waits for a later durable title.
|
||||
* A standalone `cancel` may also wait for a cwd-relative readiness marker.
|
||||
* All wait timeouts default to 10s.
|
||||
@@ -68,6 +69,7 @@ export type InputStep =
|
||||
}
|
||||
| { op: 'waitForTurnStart'; minimumTurn?: number; timeoutMs?: number }
|
||||
| { op: 'waitForTurnEnd'; timeoutMs?: number }
|
||||
| { op: 'waitForInboxMessage'; text: string; timeoutMs?: number }
|
||||
| { op: 'waitForTitleAfterTurnEnd'; timeoutMs?: number }
|
||||
| { op: 'cancel'; waitForFile?: { path: string; timeoutMs?: number } }
|
||||
|
||||
@@ -290,6 +292,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
|
||||
(id) => { sessionId = id },
|
||||
(id, timeoutMs, minimumTurn) => waitForPersistedTurnStart(sessionsRoot, id, timeoutMs, minimumTurn),
|
||||
(id, timeoutMs) => waitForPersistedTurnEnd(sessionsRoot, id, timeoutMs),
|
||||
(id, text, timeoutMs) => waitForPersistedInboxMessage(sessionsRoot, id, text, timeoutMs),
|
||||
(id, timeoutMs) => waitForPersistedTitleAfterTurnEnd(sessionsRoot, id, timeoutMs),
|
||||
)
|
||||
// A permission exchange happens while a step's request is in flight, so
|
||||
@@ -364,6 +367,7 @@ async function runStep(
|
||||
setSessionId: (id: string) => void,
|
||||
waitForTurnStart: (sessionId: string, timeoutMs?: number, minimumTurn?: number) => Promise<void>,
|
||||
waitForTurnEnd: (sessionId: string, timeoutMs?: number) => Promise<void>,
|
||||
waitForInboxMessage: (sessionId: string, text: string, timeoutMs?: number) => Promise<void>,
|
||||
waitForTitleAfterTurnEnd: (sessionId: string, timeoutMs?: number) => Promise<void>,
|
||||
): Promise<void> {
|
||||
switch (step.op) {
|
||||
@@ -442,6 +446,12 @@ async function runStep(
|
||||
await waitForTurnEnd(sessionId, step.timeoutMs)
|
||||
return
|
||||
}
|
||||
case 'waitForInboxMessage': {
|
||||
const sessionId = getSessionId()
|
||||
if (sessionId === undefined) throw new Error('snapshot-harness: waitForInboxMessage before newSession')
|
||||
await waitForInboxMessage(sessionId, step.text, step.timeoutMs)
|
||||
return
|
||||
}
|
||||
case 'waitForTitleAfterTurnEnd': {
|
||||
const sessionId = getSessionId()
|
||||
if (sessionId === undefined) throw new Error('snapshot-harness: waitForTitleAfterTurnEnd before newSession')
|
||||
@@ -515,6 +525,29 @@ async function waitForPersistedTurnEnd(
|
||||
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
|
||||
}
|
||||
|
||||
/** Wait until an inserted inbox message contains scenario-owned text. */
|
||||
async function waitForPersistedInboxMessage(
|
||||
root: string,
|
||||
sessionId: string,
|
||||
text: string,
|
||||
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
await vi.waitFor(async () => {
|
||||
const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId)
|
||||
const matched = log?.content.split('\n').some((line) => {
|
||||
if (line.length === 0) return false
|
||||
const record = JSON.parse(line) as {
|
||||
type?: unknown
|
||||
data?: { inserted?: Array<{ content?: Array<{ type?: unknown; text?: unknown }> }> }
|
||||
}
|
||||
return record.type === 'agent/inbox/spliced' && record.data?.inserted?.some(message =>
|
||||
message.content?.some(block => block.type === 'text'
|
||||
&& typeof block.text === 'string' && block.text.includes(text))) === true
|
||||
}) ?? false
|
||||
if (!matched) throw new Error(`snapshot-harness: session "${sessionId}" did not persist expected inbox message within ${timeoutMs}ms`)
|
||||
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
|
||||
}
|
||||
|
||||
/** Wait until a complete provider or fallback title record follows the latest closed turn. */
|
||||
async function waitForPersistedTitleAfterTurnEnd(
|
||||
root: string,
|
||||
|
||||
@@ -584,6 +584,55 @@ describe('runScenario', () => {
|
||||
expect(result.sessionLogs[0]?.content).toContain('"type":"turn/end"')
|
||||
})
|
||||
|
||||
it('waitForInboxMessage holds the app through a matching durable insertion', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({
|
||||
prompt: 'hang-until-cancel',
|
||||
persistLogsOnCancel: true,
|
||||
logs: [{
|
||||
file: 'project/main/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
|
||||
{
|
||||
type: 'agent/inbox/spliced',
|
||||
seq: 0,
|
||||
time: 2,
|
||||
data: {
|
||||
target: 'next-turn',
|
||||
start: 0,
|
||||
inserted: [{ role: 'user', content: [{ type: 'text', text: 'durable marker' }] }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}],
|
||||
})
|
||||
const result = await runScenario(
|
||||
{ steps: [...boot, { op: 'promptAndCancel', text: 'hang' }, { op: 'waitForInboxMessage', text: 'marker' }] },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
)
|
||||
expect(result.sessionLogs[0]?.content).toContain('durable marker')
|
||||
})
|
||||
|
||||
it('waitForInboxMessage times out when the session log or matching insertion is absent', { timeout: 20_000 }, async () => {
|
||||
const absent = await scenario({ prompt: 'hang-until-cancel', persistLogsOnCancel: true })
|
||||
await expect(runScenario(
|
||||
{ steps: [...boot, { op: 'promptAndCancel', text: 'hang' }, { op: 'waitForInboxMessage', text: 'missing', timeoutMs: 20 }] },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile: absent.fixtureFile },
|
||||
)).rejects.toThrow(/did not persist expected inbox message within 20ms/)
|
||||
|
||||
const unmatched = await scenario({
|
||||
prompt: 'hang-until-cancel',
|
||||
persistLogsOnCancel: true,
|
||||
logs: [{
|
||||
file: 'project/main/session.jsonl',
|
||||
lines: [{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 }],
|
||||
}],
|
||||
})
|
||||
await expect(runScenario(
|
||||
{ steps: [...boot, { op: 'promptAndCancel', text: 'hang' }, { op: 'waitForInboxMessage', text: 'missing', timeoutMs: 20 }] },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile: unmatched.fixtureFile },
|
||||
)).rejects.toThrow(/did not persist expected inbox message within 20ms/)
|
||||
})
|
||||
|
||||
it('waitForTitleAfterTurnEnd holds the app through a standalone durable title', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({
|
||||
prompt: 'hang-until-cancel',
|
||||
@@ -872,6 +921,7 @@ describe('runScenario', () => {
|
||||
[{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/],
|
||||
[{ op: 'waitForTurnStart' }, /waitForTurnStart before newSession/],
|
||||
[{ op: 'waitForTurnEnd' }, /waitForTurnEnd before newSession/],
|
||||
[{ op: 'waitForInboxMessage', text: 'marker' }, /waitForInboxMessage before newSession/],
|
||||
[{ op: 'waitForTitleAfterTurnEnd' }, /waitForTitleAfterTurnEnd before newSession/],
|
||||
[{ op: 'cancel' }, /cancel before newSession/],
|
||||
] as [InputStep, RegExp][])('rejects %j before newSession', { timeout: 20_000 }, async (step, message) => {
|
||||
|
||||
Reference in New Issue
Block a user