Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	examples/acp-agent/tests/goal-snapshots/goal-session/input.json
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/session-persistence/session-persistence-jsonl/src/zstd.ts
#	packages/support/acp-snapshot/src/harness.ts
This commit is contained in:
_Kerman
2026-08-04 14:14:57 +08:00
32 changed files with 432 additions and 230 deletions

View File

@@ -43,8 +43,10 @@ const WAIT_POLL_INTERVAL_MS = 10
* reference, since a committed file cannot know the id in advance.
*
* `promptAndCancel` starts a prompt without awaiting completion, waits for a
* readiness condition, then cancels and awaits completion. `waitForFile`
* observes a cwd-relative marker; the default observes the durable turn start.
* readiness condition, then cancels and awaits completion. Its optional
* `waitForFile` observes a cwd-relative marker; otherwise it waits for the
* durable turn start. The standalone `waitForFile` holds the next script step
* behind the same marker.
* `promptAndWaitForAgentMessage` arms an exact text-chunk waiter before sending
* the prompt, then keeps the application live until that later update arrives.
* `waitForTurnStart` waits for an open durable turn, optionally at or beyond a
@@ -52,9 +54,13 @@ const WAIT_POLL_INTERVAL_MS = 10
* selected session's latest complete raw-JSONL turn boundary is `turn/end`.
* `waitForGoalPhase` waits for the latest durable goal snapshot to reach one phase.
* `waitForInboxMessage` waits for inserted inbox text containing a scenario marker.
* `waitForSubagentTurnEnd` waits until one background child has persisted a
* closed model-work turn after its own descriptor; child progress has no ACP
* update to wait on.
* `waitForTitleAfterTurnEnd` additionally waits for a later durable title.
* `waitForSubagentTurnEnd` applies the same work-turn boundary to one
* background child, whose progress has no ACP update to wait on.
* `waitForEventAfterTurnEnd` waits until a complete record of the given event
* type follows the latest closed turn — for scenarios whose asserted state
* (e.g. a goal pause) is appended only after cancellation reaches idle.
* A standalone `cancel` may also wait for a cwd-relative readiness marker.
* All wait timeouts default to 10s.
*/
@@ -77,6 +83,7 @@ export type InputStep =
| { op: 'waitForGoalPhase'; phase: 'active' | 'paused' | 'blocked' | 'complete'; timeoutMs?: number }
| { op: 'waitForInboxMessage'; text: string; timeoutMs?: number }
| { op: 'waitForTitleAfterTurnEnd'; timeoutMs?: number }
| { op: 'waitForEventAfterTurnEnd'; type: string; timeoutMs?: number }
| { op: 'cancel'; waitForFile?: { path: string; timeoutMs?: number } }
/** A scenario's `input.json`: an ordered list of input steps. */
@@ -302,6 +309,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise
(id, phase, timeoutMs) => waitForPersistedGoalPhase(sessionsRoot, id, phase, timeoutMs),
(id, text, timeoutMs) => waitForPersistedInboxMessage(sessionsRoot, id, text, timeoutMs),
(id, timeoutMs) => waitForPersistedTitleAfterTurnEnd(sessionsRoot, id, timeoutMs),
(id, type, timeoutMs) => waitForPersistedEventAfterTurnEnd(sessionsRoot, id, type, timeoutMs),
)
// A permission exchange happens while a step's request is in flight, so
// by the time the step settles any script bug it exposed is captured —
@@ -379,6 +387,7 @@ async function runStep(
waitForGoalPhase: (sessionId: string, phase: string, timeoutMs?: number) => Promise<void>,
waitForInboxMessage: (sessionId: string, text: string, timeoutMs?: number) => Promise<void>,
waitForTitleAfterTurnEnd: (sessionId: string, timeoutMs?: number) => Promise<void>,
waitForEventAfterTurnEnd: (sessionId: string, type: string, timeoutMs?: number) => Promise<void>,
): Promise<void> {
switch (step.op) {
case 'initialize':
@@ -480,6 +489,12 @@ async function runStep(
await waitForTitleAfterTurnEnd(sessionId, step.timeoutMs)
return
}
case 'waitForEventAfterTurnEnd': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: waitForEventAfterTurnEnd before newSession')
await waitForEventAfterTurnEnd(sessionId, step.type, step.timeoutMs)
return
}
case 'waitForTurnStart': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: waitForTurnStart before newSession')
@@ -650,6 +665,21 @@ async function waitForPersistedTitleAfterTurnEnd(
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/** Wait until a complete record of `type` follows the latest closed turn. */
async function waitForPersistedEventAfterTurnEnd(
root: string,
sessionId: string,
type: string,
timeoutMs = DEFAULT_WAIT_TIMEOUT_MS,
): Promise<void> {
await vi.waitFor(async () => {
const log = (await harvestSessionLogs(root)).find(candidate => candidate.id === sessionId)
if (log === undefined || !latestEventFollowsTurnEnd(log.content, type)) {
throw new Error(`snapshot-harness: session "${sessionId}" did not persist ${type} after turn/end within ${timeoutMs}ms`)
}
}, { interval: WAIT_POLL_INTERVAL_MS, timeout: timeoutMs })
}
/** Wait for a cwd-relative marker proving an external action reached readiness. */
async function waitForWorkspaceFile(
cwd: string,
@@ -678,6 +708,13 @@ function latestTitleFollowsTurnEnd(content: string): boolean {
return turnEnd >= 0 && complete.lastIndexOf('\n{"type":"session/title",') > turnEnd
}
/** Return whether a complete record of `type` occurs after the last complete turn end. */
function latestEventFollowsTurnEnd(content: string, type: string): boolean {
const complete = content.slice(0, content.lastIndexOf('\n') + 1)
const turnEnd = complete.lastIndexOf('\n{"type":"turn/end",')
return turnEnd >= 0 && complete.lastIndexOf(`\n{"type":"${type}",`) > turnEnd
}
/** Return the latest open turn number, validating the persisted boundary record. */
function latestOpenTurn(content: string): number | undefined {
const complete = content.slice(0, content.lastIndexOf('\n') + 1)

View File

@@ -935,6 +935,55 @@ describe('runScenario', () => {
)).rejects.toThrow(/did not persist session\/title after turn\/end within 20ms/)
})
it('waitForEventAfterTurnEnd holds the app for a typed post-boundary record and times out otherwise', { timeout: 20_000 }, async () => {
const late = 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: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
{ type: 'user/message', seq: 2, time: 3, data: { content: [{ type: 'text', text: 'late goal state' }], source: { kind: 'user' } } },
],
}],
})
const result = await runScenario(
{
steps: [
...boot,
{ op: 'promptAndCancel', text: 'hang' },
{ op: 'waitForEventAfterTurnEnd', type: 'user/message' },
],
},
{ agent: AGENT, mode: 'replay', fixtureFile: late.fixtureFile },
)
expect(result.sessionLogs[0]?.content).toMatch(/"turn\/end"[\s\S]*"user\/message"/)
const early = 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: 'user/message', seq: 1, time: 1, data: { content: [{ type: 'text', text: 'early' }], source: { kind: 'user' } } },
{ type: 'turn/end', seq: 2, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
],
}],
})
await expect(runScenario(
{
steps: [
...boot,
{ op: 'promptAndCancel', text: 'hang' },
{ op: 'waitForEventAfterTurnEnd', type: 'user/message', timeoutMs: 20 },
],
},
{ agent: AGENT, mode: 'replay', fixtureFile: early.fixtureFile },
)).rejects.toThrow(/did not persist user\/message after turn\/end within 20ms/)
})
it('promptExpectError swallows a model-error response as the expected outcome', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ prompt: 'error' })
const result = await runScenario(
@@ -1056,6 +1105,7 @@ describe('runScenario', () => {
[{ op: 'waitForGoalPhase', phase: 'active' }, /waitForGoalPhase before newSession/],
[{ op: 'waitForInboxMessage', text: 'marker' }, /waitForInboxMessage before newSession/],
[{ op: 'waitForTitleAfterTurnEnd' }, /waitForTitleAfterTurnEnd before newSession/],
[{ op: 'waitForEventAfterTurnEnd', type: 'user/message' }, /waitForEventAfterTurnEnd before newSession/],
[{ op: 'cancel' }, /cancel before newSession/],
] as [InputStep, RegExp][])('rejects %j before newSession', { timeout: 20_000 }, async (step, message) => {
const { fixtureFile } = await scenario({})