@nifrajs/agent-protocol 4.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/dist/conformance.d.ts +41 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +393 -0
- package/dist/conformance.js.map +1 -0
- package/dist/index.d.ts +97 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +348 -92
- package/dist/index.js.map +1 -1
- package/package.json +6 -1
- package/src/conformance.ts +490 -0
- package/src/index.ts +467 -94
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A conformance kit for `AgentBackend` implementations: it drives a backend through the scenarios a
|
|
3
|
+
* host relies on and reports each check, so an adapter for a vendor's agent can prove it before a
|
|
4
|
+
* host ships it. It uses no test runner; assert on the returned report in whichever one you use.
|
|
5
|
+
*/
|
|
6
|
+
import { type AgentBackend, type AgentSessionSnapshot, agentEventProblem } from "./index.ts"
|
|
7
|
+
|
|
8
|
+
export interface AgentBackendConformanceOptions {
|
|
9
|
+
/** A fresh backend for each scenario. The kit closes every session it opens. */
|
|
10
|
+
readonly backend: () => AgentBackend | PromiseLike<AgentBackend>
|
|
11
|
+
/** The working directory each session is created in. */
|
|
12
|
+
readonly cwd: string
|
|
13
|
+
/** A message the backend answers with a turn that ends by itself. */
|
|
14
|
+
readonly prompt: string
|
|
15
|
+
/** A message that makes the backend ask for an approval. Enables the approval checks. */
|
|
16
|
+
readonly approvalPrompt?: string
|
|
17
|
+
/** A message whose turn keeps running until it is cancelled. Enables the busy and cancel checks. */
|
|
18
|
+
readonly slowPrompt?: string
|
|
19
|
+
/** Longest one scenario may wait for the backend. Default 10 seconds. */
|
|
20
|
+
readonly timeoutMs?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type AgentConformanceStatus = "passed" | "failed" | "skipped"
|
|
24
|
+
|
|
25
|
+
export interface AgentConformanceCheck {
|
|
26
|
+
readonly id: string
|
|
27
|
+
readonly status: AgentConformanceStatus
|
|
28
|
+
/** What went wrong, or why the check was skipped. */
|
|
29
|
+
readonly detail?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AgentBackendConformanceReport {
|
|
33
|
+
readonly backend: string
|
|
34
|
+
/** No check failed. Skipped checks do not count against it. */
|
|
35
|
+
readonly ok: boolean
|
|
36
|
+
readonly checks: readonly AgentConformanceCheck[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type ReadOutcome = "matched" | "ended" | "threw" | "timeout"
|
|
40
|
+
|
|
41
|
+
const TERMINAL: ReadonlySet<unknown> = new Set([
|
|
42
|
+
"session.completed",
|
|
43
|
+
"session.failed",
|
|
44
|
+
"session.stopped",
|
|
45
|
+
])
|
|
46
|
+
const DEFAULT_TIMEOUT_MS = 10_000
|
|
47
|
+
|
|
48
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
49
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function recordOf(value: unknown): Record<string, unknown> | undefined {
|
|
53
|
+
return isRecord(value) ? value : undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function typeOf(event: unknown): unknown {
|
|
57
|
+
return recordOf(event)?.type
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Reads a backend's stream one event at a time, never waiting past a deadline. */
|
|
61
|
+
class EventReader {
|
|
62
|
+
readonly events: unknown[] = []
|
|
63
|
+
error: unknown
|
|
64
|
+
private readonly iterator: AsyncIterator<unknown> | undefined
|
|
65
|
+
private pending: Promise<IteratorResult<unknown>> | undefined
|
|
66
|
+
private finished = false
|
|
67
|
+
|
|
68
|
+
constructor(open: () => AsyncIterable<unknown>) {
|
|
69
|
+
try {
|
|
70
|
+
this.iterator = open()[Symbol.asyncIterator]()
|
|
71
|
+
} catch (error) {
|
|
72
|
+
this.error = error
|
|
73
|
+
this.finished = true
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async readUntil(until: (event: unknown) => boolean, deadline: number): Promise<ReadOutcome> {
|
|
78
|
+
for (;;) {
|
|
79
|
+
if (this.finished) return this.error === undefined ? "ended" : "threw"
|
|
80
|
+
const iterator = this.iterator
|
|
81
|
+
if (iterator === undefined) return "ended"
|
|
82
|
+
this.pending ??= iterator.next()
|
|
83
|
+
const result = await settleBefore(this.pending, deadline)
|
|
84
|
+
if (result === TIMED_OUT) return "timeout"
|
|
85
|
+
this.pending = undefined
|
|
86
|
+
if (result.kind === "error") {
|
|
87
|
+
this.error = result.error ?? new Error("stream failed")
|
|
88
|
+
this.finished = true
|
|
89
|
+
return "threw"
|
|
90
|
+
}
|
|
91
|
+
if (result.value.done === true) {
|
|
92
|
+
this.finished = true
|
|
93
|
+
return "ended"
|
|
94
|
+
}
|
|
95
|
+
this.events.push(result.value.value)
|
|
96
|
+
if (until(result.value.value)) return "matched"
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
readToEnd(deadline: number): Promise<ReadOutcome> {
|
|
101
|
+
return this.readUntil(() => false, deadline)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async stop(): Promise<void> {
|
|
105
|
+
if (this.finished) return
|
|
106
|
+
this.finished = true
|
|
107
|
+
const closing = Promise.resolve()
|
|
108
|
+
.then(() => this.iterator?.return?.())
|
|
109
|
+
.catch(() => undefined)
|
|
110
|
+
// A generator parked in an await cannot return until it resumes, so do not wait on that one.
|
|
111
|
+
if (this.pending === undefined) await settleBefore(closing, Date.now() + 1_000)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const TIMED_OUT: unique symbol = Symbol("timed out")
|
|
116
|
+
|
|
117
|
+
type Settled<T> =
|
|
118
|
+
| { readonly kind: "value"; readonly value: T }
|
|
119
|
+
| { readonly kind: "error"; readonly error: unknown }
|
|
120
|
+
|
|
121
|
+
function settleBefore<T>(
|
|
122
|
+
promise: Promise<T>,
|
|
123
|
+
deadline: number,
|
|
124
|
+
): Promise<Settled<T> | typeof TIMED_OUT> {
|
|
125
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
126
|
+
const timeout = new Promise<typeof TIMED_OUT>((resolve) => {
|
|
127
|
+
timer = setTimeout(() => resolve(TIMED_OUT), Math.max(0, deadline - Date.now()))
|
|
128
|
+
})
|
|
129
|
+
const settled = promise.then(
|
|
130
|
+
(value): Settled<T> => ({ kind: "value", value }),
|
|
131
|
+
(error: unknown): Settled<T> => ({ kind: "error", error }),
|
|
132
|
+
)
|
|
133
|
+
return Promise.race([settled, timeout]).finally(() => clearTimeout(timer))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function describe(error: unknown): string {
|
|
137
|
+
if (error instanceof Error) return error.message
|
|
138
|
+
const message = recordOf(error)?.message
|
|
139
|
+
return typeof message === "string" ? message : String(error)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The first way the events break the protocol, or undefined when they are a valid turn. */
|
|
143
|
+
function turnProblem(
|
|
144
|
+
events: readonly unknown[],
|
|
145
|
+
sessionId: string,
|
|
146
|
+
expectEnd: boolean,
|
|
147
|
+
): string | undefined {
|
|
148
|
+
let lastSeq = -1
|
|
149
|
+
let turnId: unknown
|
|
150
|
+
for (const [index, event] of events.entries()) {
|
|
151
|
+
const problem = agentEventProblem(event)
|
|
152
|
+
if (problem !== undefined || !isRecord(event))
|
|
153
|
+
return `event ${index}: ${problem ?? "not an object"}`
|
|
154
|
+
const record = event
|
|
155
|
+
if (record.sessionId !== sessionId) return `event ${index} names another session`
|
|
156
|
+
const seq = typeof record.seq === "number" ? record.seq : -1
|
|
157
|
+
if (seq <= lastSeq) return `event ${index} has seq ${seq} after ${lastSeq}`
|
|
158
|
+
lastSeq = seq
|
|
159
|
+
if (record.type === "turn.started") {
|
|
160
|
+
if (turnId !== undefined) return `event ${index} starts a second turn`
|
|
161
|
+
turnId = record.turnId
|
|
162
|
+
} else if (record.turnId !== undefined && record.turnId !== turnId) {
|
|
163
|
+
return turnId === undefined
|
|
164
|
+
? `event ${index} (${String(record.type)}) comes before turn.started`
|
|
165
|
+
: `event ${index} (${String(record.type)}) names another turn`
|
|
166
|
+
}
|
|
167
|
+
if (TERMINAL.has(record.type) && index !== events.length - 1)
|
|
168
|
+
return `event ${index} (${String(record.type)}) ends the turn but more events follow`
|
|
169
|
+
}
|
|
170
|
+
if (turnId === undefined) return "no turn.started event"
|
|
171
|
+
if (expectEnd && !TERMINAL.has(typeOf(events.at(-1))))
|
|
172
|
+
return "the stream ended without session.completed, session.failed, or session.stopped"
|
|
173
|
+
return undefined
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Drive a backend through the scenarios a host depends on: a session's snapshot, a whole turn's
|
|
178
|
+
* events (valid, in order, one turn, one terminal event), a refused second message while busy,
|
|
179
|
+
* cancellation, approvals granted, denied, unknown and cancelled, and a closed session. Scenarios
|
|
180
|
+
* whose prompt is not given are reported as skipped.
|
|
181
|
+
*/
|
|
182
|
+
export async function checkAgentBackend(
|
|
183
|
+
options: AgentBackendConformanceOptions,
|
|
184
|
+
): Promise<AgentBackendConformanceReport> {
|
|
185
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
186
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1)
|
|
187
|
+
throw new RangeError("checkAgentBackend: timeoutMs must be a positive integer")
|
|
188
|
+
const checks: AgentConformanceCheck[] = []
|
|
189
|
+
let backendName = "unknown"
|
|
190
|
+
const pass = (id: string): void => {
|
|
191
|
+
checks.push({ id, status: "passed" })
|
|
192
|
+
}
|
|
193
|
+
const fail = (id: string, detail: string): void => {
|
|
194
|
+
checks.push({ id, status: "failed", detail })
|
|
195
|
+
}
|
|
196
|
+
const skip = (id: string, detail: string): void => {
|
|
197
|
+
checks.push({ id, status: "skipped", detail })
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const scenario = async (
|
|
201
|
+
ids: readonly string[],
|
|
202
|
+
run: (backend: AgentBackend, session: AgentSessionSnapshot, deadline: number) => Promise<void>,
|
|
203
|
+
): Promise<void> => {
|
|
204
|
+
let backend: AgentBackend | undefined
|
|
205
|
+
let session: AgentSessionSnapshot | undefined
|
|
206
|
+
const before = checks.length
|
|
207
|
+
try {
|
|
208
|
+
backend = await options.backend()
|
|
209
|
+
backendName = backend.info.name
|
|
210
|
+
session = await backend.createSession({ cwd: options.cwd })
|
|
211
|
+
await run(backend, session, Date.now() + timeoutMs)
|
|
212
|
+
} catch (error) {
|
|
213
|
+
const reported = new Set(checks.slice(before).map((check) => check.id))
|
|
214
|
+
for (const id of ids)
|
|
215
|
+
if (!reported.has(id)) fail(id, `the scenario threw: ${describe(error)}`)
|
|
216
|
+
} finally {
|
|
217
|
+
if (backend !== undefined && session !== undefined)
|
|
218
|
+
await settleBefore(backend.close(session.id), Date.now() + timeoutMs)
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await scenario(
|
|
223
|
+
["session.create", "turn.events", "turn.completes", "turn.snapshot"],
|
|
224
|
+
async (backend, session, deadline) => {
|
|
225
|
+
const problem = agentEventProblem({
|
|
226
|
+
version: 1,
|
|
227
|
+
sessionId: session.id,
|
|
228
|
+
seq: 0,
|
|
229
|
+
at: 0,
|
|
230
|
+
type: "session.started",
|
|
231
|
+
snapshot: session,
|
|
232
|
+
})
|
|
233
|
+
if (problem !== undefined) fail("session.create", problem.replace("session.started.", ""))
|
|
234
|
+
else if (session.cwd !== options.cwd)
|
|
235
|
+
fail("session.create", "snapshot.cwd is not the cwd asked for")
|
|
236
|
+
else if (session.status === "running")
|
|
237
|
+
fail("session.create", "a new session is already running")
|
|
238
|
+
else pass("session.create")
|
|
239
|
+
|
|
240
|
+
const reader = new EventReader(() =>
|
|
241
|
+
backend.send({ sessionId: session.id, message: options.prompt }),
|
|
242
|
+
)
|
|
243
|
+
const outcome = await reader.readToEnd(deadline)
|
|
244
|
+
if (outcome === "timeout") {
|
|
245
|
+
await reader.stop()
|
|
246
|
+
fail("turn.events", `the turn did not end within ${timeoutMs} ms`)
|
|
247
|
+
fail("turn.completes", "the turn did not end")
|
|
248
|
+
skip("turn.snapshot", "the turn did not end")
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
if (outcome === "threw") {
|
|
252
|
+
fail("turn.events", `the stream threw: ${describe(reader.error)}`)
|
|
253
|
+
fail("turn.completes", "the stream threw")
|
|
254
|
+
skip("turn.snapshot", "the turn did not end")
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
const eventsProblem = turnProblem(reader.events, session.id, true)
|
|
258
|
+
if (eventsProblem === undefined) pass("turn.events")
|
|
259
|
+
else fail("turn.events", eventsProblem)
|
|
260
|
+
const last = typeOf(reader.events.at(-1))
|
|
261
|
+
if (last === "session.completed") pass("turn.completes")
|
|
262
|
+
else
|
|
263
|
+
fail(
|
|
264
|
+
"turn.completes",
|
|
265
|
+
`the turn ended with ${String(last ?? "no event")}, not session.completed`,
|
|
266
|
+
)
|
|
267
|
+
const after = await backend.snapshot(session.id)
|
|
268
|
+
if (after.id !== session.id) fail("turn.snapshot", "snapshot names another session")
|
|
269
|
+
else if (after.status === "running")
|
|
270
|
+
fail("turn.snapshot", "the session is still running after its turn")
|
|
271
|
+
else pass("turn.snapshot")
|
|
272
|
+
},
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
const slowPrompt = options.slowPrompt
|
|
276
|
+
if (slowPrompt === undefined) {
|
|
277
|
+
skip("turn.busy", "no slowPrompt")
|
|
278
|
+
skip("turn.cancel", "no slowPrompt")
|
|
279
|
+
} else {
|
|
280
|
+
await scenario(["turn.busy"], async (backend, session, deadline) => {
|
|
281
|
+
const first = new EventReader(() =>
|
|
282
|
+
backend.send({ sessionId: session.id, message: slowPrompt }),
|
|
283
|
+
)
|
|
284
|
+
if (
|
|
285
|
+
(await first.readUntil((event) => typeOf(event) === "turn.started", deadline)) !== "matched"
|
|
286
|
+
) {
|
|
287
|
+
await first.stop()
|
|
288
|
+
fail("turn.busy", "the slow turn never started")
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
const second = new EventReader(() =>
|
|
292
|
+
backend.send({ sessionId: session.id, message: options.prompt }),
|
|
293
|
+
)
|
|
294
|
+
const outcome = await second.readUntil((event) => TERMINAL.has(typeOf(event)), deadline)
|
|
295
|
+
const refused =
|
|
296
|
+
outcome === "threw" ||
|
|
297
|
+
(outcome === "matched" &&
|
|
298
|
+
second.events.length === 1 &&
|
|
299
|
+
typeOf(second.events[0]) === "session.failed")
|
|
300
|
+
if (!refused)
|
|
301
|
+
fail("turn.busy", "a second message while a turn runs was not refused on its own stream")
|
|
302
|
+
else if ((await backend.snapshot(session.id)).status !== "running")
|
|
303
|
+
fail("turn.busy", "refusing the second message ended the running turn")
|
|
304
|
+
else pass("turn.busy")
|
|
305
|
+
await second.stop()
|
|
306
|
+
await backend.cancel(session.id, "conformance")
|
|
307
|
+
await first.readToEnd(deadline)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
await scenario(["turn.cancel"], async (backend, session, deadline) => {
|
|
311
|
+
const reader = new EventReader(() =>
|
|
312
|
+
backend.send({ sessionId: session.id, message: slowPrompt }),
|
|
313
|
+
)
|
|
314
|
+
if (
|
|
315
|
+
(await reader.readUntil((event) => typeOf(event) === "turn.started", deadline)) !==
|
|
316
|
+
"matched"
|
|
317
|
+
) {
|
|
318
|
+
await reader.stop()
|
|
319
|
+
fail("turn.cancel", "the slow turn never started")
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
await backend.cancel(session.id, "conformance")
|
|
323
|
+
const outcome = await reader.readToEnd(deadline)
|
|
324
|
+
if (outcome === "timeout") {
|
|
325
|
+
await reader.stop()
|
|
326
|
+
fail("turn.cancel", `the turn did not end within ${timeoutMs} ms of cancel`)
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
const problem = turnProblem(reader.events, session.id, true)
|
|
330
|
+
if (problem !== undefined) {
|
|
331
|
+
fail("turn.cancel", problem)
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
if (typeOf(reader.events.at(-1)) !== "session.stopped") {
|
|
335
|
+
fail("turn.cancel", `a cancelled turn ended with ${String(typeOf(reader.events.at(-1)))}`)
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
const next = new EventReader(() =>
|
|
339
|
+
backend.send({ sessionId: session.id, message: options.prompt }),
|
|
340
|
+
)
|
|
341
|
+
if (
|
|
342
|
+
(await next.readToEnd(deadline)) !== "ended" ||
|
|
343
|
+
typeOf(next.events.at(-1)) !== "session.completed"
|
|
344
|
+
) {
|
|
345
|
+
await next.stop()
|
|
346
|
+
fail("turn.cancel", "the session did not complete a turn after a cancel")
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
pass("turn.cancel")
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const approvalPrompt = options.approvalPrompt
|
|
354
|
+
if (approvalPrompt === undefined) {
|
|
355
|
+
for (const id of ["approval.allow", "approval.deny", "approval.unknown", "approval.cancel"])
|
|
356
|
+
skip(id, "no approvalPrompt")
|
|
357
|
+
} else {
|
|
358
|
+
const decide = (id: string, approved: boolean) =>
|
|
359
|
+
scenario([id], async (backend, session, deadline) => {
|
|
360
|
+
const reader = new EventReader(() =>
|
|
361
|
+
backend.send({ sessionId: session.id, message: approvalPrompt }),
|
|
362
|
+
)
|
|
363
|
+
const asked = await reader.readUntil(
|
|
364
|
+
(event) => typeOf(event) === "approval.required",
|
|
365
|
+
deadline,
|
|
366
|
+
)
|
|
367
|
+
if (asked !== "matched") {
|
|
368
|
+
await reader.stop()
|
|
369
|
+
fail(id, "the turn never asked for an approval")
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
const approvalId = recordOf(reader.events.at(-1))?.approvalId
|
|
373
|
+
if (typeof approvalId !== "string") {
|
|
374
|
+
await reader.stop()
|
|
375
|
+
fail(id, "approval.required carried no approvalId")
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
if (backend.resolveApproval === undefined) {
|
|
379
|
+
await reader.stop()
|
|
380
|
+
fail(id, "the backend asks for approvals but has no resolveApproval")
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
const accepted = await backend.resolveApproval(
|
|
384
|
+
session.id,
|
|
385
|
+
approvalId,
|
|
386
|
+
approved,
|
|
387
|
+
"conformance",
|
|
388
|
+
)
|
|
389
|
+
if (accepted === undefined) {
|
|
390
|
+
await reader.stop()
|
|
391
|
+
fail(id, "resolveApproval did not know the approval it asked for")
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
const outcome = await reader.readToEnd(deadline)
|
|
395
|
+
if (outcome !== "ended") {
|
|
396
|
+
await reader.stop()
|
|
397
|
+
fail(
|
|
398
|
+
id,
|
|
399
|
+
outcome === "timeout" ? "the turn did not end after the answer" : "the stream threw",
|
|
400
|
+
)
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
const problem = turnProblem(reader.events, session.id, true)
|
|
404
|
+
// A host records the answer itself, so a backend need not echo it; one that does must agree.
|
|
405
|
+
const contradicted = reader.events.some((event) => {
|
|
406
|
+
const record = recordOf(event)
|
|
407
|
+
return (
|
|
408
|
+
record?.type === "approval.resolved" &&
|
|
409
|
+
record.approvalId === approvalId &&
|
|
410
|
+
record.approved !== approved
|
|
411
|
+
)
|
|
412
|
+
})
|
|
413
|
+
if (problem !== undefined) fail(id, problem)
|
|
414
|
+
else if (contradicted)
|
|
415
|
+
fail(id, `approval.resolved for ${approvalId} contradicts the answer`)
|
|
416
|
+
else pass(id)
|
|
417
|
+
})
|
|
418
|
+
await decide("approval.allow", true)
|
|
419
|
+
await decide("approval.deny", false)
|
|
420
|
+
|
|
421
|
+
await scenario(["approval.unknown"], async (backend, session) => {
|
|
422
|
+
if (backend.resolveApproval === undefined) {
|
|
423
|
+
skip("approval.unknown", "the backend has no resolveApproval")
|
|
424
|
+
return
|
|
425
|
+
}
|
|
426
|
+
const answer = await backend.resolveApproval(session.id, "conformance-unknown-approval", true)
|
|
427
|
+
if (answer !== undefined)
|
|
428
|
+
fail(
|
|
429
|
+
"approval.unknown",
|
|
430
|
+
"an approval nobody asked for took a decision instead of undefined",
|
|
431
|
+
)
|
|
432
|
+
else pass("approval.unknown")
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
await scenario(["approval.cancel"], async (backend, session, deadline) => {
|
|
436
|
+
const reader = new EventReader(() =>
|
|
437
|
+
backend.send({ sessionId: session.id, message: approvalPrompt }),
|
|
438
|
+
)
|
|
439
|
+
if (
|
|
440
|
+
(await reader.readUntil((event) => typeOf(event) === "approval.required", deadline)) !==
|
|
441
|
+
"matched"
|
|
442
|
+
) {
|
|
443
|
+
await reader.stop()
|
|
444
|
+
fail("approval.cancel", "the turn never asked for an approval")
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
const approvalId = recordOf(reader.events.at(-1))?.approvalId
|
|
448
|
+
await backend.cancel(session.id, "conformance")
|
|
449
|
+
if ((await reader.readToEnd(deadline)) !== "ended") {
|
|
450
|
+
await reader.stop()
|
|
451
|
+
fail("approval.cancel", "a turn waiting on an approval did not end after cancel")
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
if (typeOf(reader.events.at(-1)) !== "session.stopped") {
|
|
455
|
+
fail(
|
|
456
|
+
"approval.cancel",
|
|
457
|
+
`the cancelled turn ended with ${String(typeOf(reader.events.at(-1)))}`,
|
|
458
|
+
)
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
const late =
|
|
462
|
+
typeof approvalId === "string"
|
|
463
|
+
? await backend.resolveApproval?.(session.id, approvalId, true)
|
|
464
|
+
: undefined
|
|
465
|
+
if (late !== undefined)
|
|
466
|
+
fail("approval.cancel", "an approval stayed open after its turn was cancelled")
|
|
467
|
+
else pass("approval.cancel")
|
|
468
|
+
})
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
await scenario(["session.close"], async (backend, session, deadline) => {
|
|
472
|
+
await backend.close(session.id)
|
|
473
|
+
const reader = new EventReader(() =>
|
|
474
|
+
backend.send({ sessionId: session.id, message: options.prompt }),
|
|
475
|
+
)
|
|
476
|
+
const outcome = await reader.readToEnd(deadline)
|
|
477
|
+
if (outcome === "timeout") {
|
|
478
|
+
await reader.stop()
|
|
479
|
+
fail("session.close", "a message to a closed session neither failed nor ended")
|
|
480
|
+
} else if (
|
|
481
|
+
outcome === "threw" ||
|
|
482
|
+
(reader.events.length > 0 &&
|
|
483
|
+
reader.events.every((event) => typeOf(event) === "session.failed"))
|
|
484
|
+
)
|
|
485
|
+
pass("session.close")
|
|
486
|
+
else fail("session.close", "a closed session ran a turn")
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
return { backend: backendName, ok: checks.every((check) => check.status !== "failed"), checks }
|
|
490
|
+
}
|