@agent-native/agentkit 0.1.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/ARCHITECTURE.md +290 -0
- package/README.md +822 -0
- package/dist/adapters/http.d.ts +56 -0
- package/dist/adapters/http.d.ts.map +1 -0
- package/dist/adapters/http.js +950 -0
- package/dist/adapters/http.js.map +1 -0
- package/dist/adapters/index.d.ts +2 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +2 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/client/client.d.ts +240 -0
- package/dist/client/client.d.ts.map +1 -0
- package/dist/client/client.js +1605 -0
- package/dist/client/client.js.map +1 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +3 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/state.d.ts +72 -0
- package/dist/client/state.d.ts.map +1 -0
- package/dist/client/state.js +385 -0
- package/dist/client/state.js.map +1 -0
- package/dist/conformance/index.d.ts +41 -0
- package/dist/conformance/index.d.ts.map +1 -0
- package/dist/conformance/index.js +924 -0
- package/dist/conformance/index.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol/agui-codec.d.ts +28 -0
- package/dist/protocol/agui-codec.d.ts.map +1 -0
- package/dist/protocol/agui-codec.js +250 -0
- package/dist/protocol/agui-codec.js.map +1 -0
- package/dist/protocol/agui.d.ts +144 -0
- package/dist/protocol/agui.d.ts.map +1 -0
- package/dist/protocol/agui.js +253 -0
- package/dist/protocol/agui.js.map +1 -0
- package/dist/protocol/compatibility.d.ts +35 -0
- package/dist/protocol/compatibility.d.ts.map +1 -0
- package/dist/protocol/compatibility.js +160 -0
- package/dist/protocol/compatibility.js.map +1 -0
- package/dist/protocol/errors.d.ts +24 -0
- package/dist/protocol/errors.d.ts.map +1 -0
- package/dist/protocol/errors.js +77 -0
- package/dist/protocol/errors.js.map +1 -0
- package/dist/protocol/index.d.ts +982 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/index.js +56 -0
- package/dist/protocol/index.js.map +1 -0
- package/dist/protocol/validation.d.ts +94 -0
- package/dist/protocol/validation.d.ts.map +1 -0
- package/dist/protocol/validation.js +1871 -0
- package/dist/protocol/validation.js.map +1 -0
- package/dist/protocol/version.d.ts +7 -0
- package/dist/protocol/version.d.ts.map +1 -0
- package/dist/protocol/version.js +9 -0
- package/dist/protocol/version.js.map +1 -0
- package/dist/react/chat.d.ts +34 -0
- package/dist/react/chat.d.ts.map +1 -0
- package/dist/react/chat.js +23 -0
- package/dist/react/chat.js.map +1 -0
- package/dist/react/components.d.ts +96 -0
- package/dist/react/components.d.ts.map +1 -0
- package/dist/react/components.js +1300 -0
- package/dist/react/components.js.map +1 -0
- package/dist/react/context.d.ts +259 -0
- package/dist/react/context.d.ts.map +1 -0
- package/dist/react/context.js +359 -0
- package/dist/react/context.js.map +1 -0
- package/dist/react/headless.d.ts +3 -0
- package/dist/react/headless.d.ts.map +1 -0
- package/dist/react/headless.js +3 -0
- package/dist/react/headless.js.map +1 -0
- package/dist/react/index.d.ts +6 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +6 -0
- package/dist/react/index.js.map +1 -0
- package/dist/react/root.d.ts +58 -0
- package/dist/react/root.d.ts.map +1 -0
- package/dist/react/root.js +151 -0
- package/dist/react/root.js.map +1 -0
- package/dist/react/streaming-text.d.ts +19 -0
- package/dist/react/streaming-text.d.ts.map +1 -0
- package/dist/react/streaming-text.js +259 -0
- package/dist/react/streaming-text.js.map +1 -0
- package/dist/react/styles.css +1477 -0
- package/package.json +112 -0
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
import { AGENTKIT_PROTOCOL_VERSION, createAgentKitProtocolVersionOffer, parseAgentCapabilities, decodeAgUiEvent, encodeAgentEvent, isAgUiEvent, parseAgentEvent, parseAgentEventSequence, resumeEntryFromApproval, parseAgentQueuedMessage, parseAgentThreadSnapshot, projectAgentCapabilities, } from "../protocol/index.js";
|
|
2
|
+
export class AgentKitConformanceError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "AgentKitConformanceError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 2_000;
|
|
9
|
+
function assert(condition, message) {
|
|
10
|
+
if (!condition)
|
|
11
|
+
throw new AgentKitConformanceError(message);
|
|
12
|
+
}
|
|
13
|
+
function unsupportedByDefault(error) {
|
|
14
|
+
if (!error || typeof error !== "object")
|
|
15
|
+
return false;
|
|
16
|
+
const candidate = error;
|
|
17
|
+
return (candidate.code === "unsupported_capability" ||
|
|
18
|
+
(typeof candidate.message === "string" &&
|
|
19
|
+
/(?:unsupported|not supported|capabilit)/iu.test(candidate.message)));
|
|
20
|
+
}
|
|
21
|
+
async function withTimeout(promise, timeoutMs, label) {
|
|
22
|
+
let timeout;
|
|
23
|
+
try {
|
|
24
|
+
return await Promise.race([
|
|
25
|
+
promise,
|
|
26
|
+
new Promise((_, reject) => {
|
|
27
|
+
timeout = setTimeout(() => reject(new AgentKitConformanceError(`${label} did not settle within ${timeoutMs}ms.`)), timeoutMs);
|
|
28
|
+
}),
|
|
29
|
+
]);
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
if (timeout)
|
|
33
|
+
clearTimeout(timeout);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function collectIterator(iterator, timeoutMs, label) {
|
|
37
|
+
return withTimeout((async () => {
|
|
38
|
+
const events = [];
|
|
39
|
+
while (true) {
|
|
40
|
+
const next = await iterator.next();
|
|
41
|
+
if (next.done)
|
|
42
|
+
return events;
|
|
43
|
+
events.push(parseAgentEvent(next.value));
|
|
44
|
+
}
|
|
45
|
+
})(), timeoutMs, label);
|
|
46
|
+
}
|
|
47
|
+
async function collect(iterable, timeoutMs, label) {
|
|
48
|
+
return collectIterator(iterable[Symbol.asyncIterator](), timeoutMs, label);
|
|
49
|
+
}
|
|
50
|
+
function terminalStatus(event) {
|
|
51
|
+
if (event.type === "run.completed")
|
|
52
|
+
return "completed";
|
|
53
|
+
if (event.type === "run.failed")
|
|
54
|
+
return "failed";
|
|
55
|
+
return "cancelled";
|
|
56
|
+
}
|
|
57
|
+
function isTerminal(event) {
|
|
58
|
+
return (event?.type === "run.completed" ||
|
|
59
|
+
event?.type === "run.failed" ||
|
|
60
|
+
event?.type === "run.cancelled");
|
|
61
|
+
}
|
|
62
|
+
function assertTerminalActivities(events) {
|
|
63
|
+
const activities = new Map();
|
|
64
|
+
for (const event of events) {
|
|
65
|
+
if (event.type === "activity.started" ||
|
|
66
|
+
event.type === "activity.updated" ||
|
|
67
|
+
event.type === "activity.completed") {
|
|
68
|
+
activities.set(event.activity.id, event);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const running = [...activities.values()].filter((event) => event.activity.status === "running");
|
|
72
|
+
assert(running.length === 0, `A terminal run must close every running activity; still running: ${running.map((event) => event.activity.id).join(", ")}.`);
|
|
73
|
+
}
|
|
74
|
+
function createLifecycleProjection() {
|
|
75
|
+
return {
|
|
76
|
+
annotations: new Map(),
|
|
77
|
+
widgets: new Map(),
|
|
78
|
+
taskGroups: new Map(),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function applyLifecycleEvent(projection, event) {
|
|
82
|
+
switch (event.type) {
|
|
83
|
+
case "annotation.created":
|
|
84
|
+
case "annotation.updated": {
|
|
85
|
+
const previous = projection.annotations.get(event.annotation.id);
|
|
86
|
+
projection.annotations.set(event.annotation.id, {
|
|
87
|
+
value: event.annotation,
|
|
88
|
+
...((event.messageId ?? previous?.messageId)
|
|
89
|
+
? { messageId: event.messageId ?? previous?.messageId }
|
|
90
|
+
: {}),
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
case "annotation.removed":
|
|
95
|
+
projection.annotations.delete(event.annotationId);
|
|
96
|
+
return;
|
|
97
|
+
case "widget.created":
|
|
98
|
+
case "widget.updated": {
|
|
99
|
+
const previous = projection.widgets.get(event.widget.id);
|
|
100
|
+
projection.widgets.set(event.widget.id, {
|
|
101
|
+
value: event.widget,
|
|
102
|
+
...((event.messageId ?? previous?.messageId)
|
|
103
|
+
? { messageId: event.messageId ?? previous?.messageId }
|
|
104
|
+
: {}),
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
case "widget.removed":
|
|
109
|
+
projection.widgets.delete(event.widgetId);
|
|
110
|
+
return;
|
|
111
|
+
case "task-group.created":
|
|
112
|
+
case "task-group.updated":
|
|
113
|
+
case "task-group.completed":
|
|
114
|
+
projection.taskGroups.set(event.taskGroup.id, event.taskGroup);
|
|
115
|
+
return;
|
|
116
|
+
case "task-group.removed":
|
|
117
|
+
projection.taskGroups.delete(event.taskGroupId);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function serializeLifecycleProjection(projection) {
|
|
121
|
+
const entries = (map) => [...map.entries()].sort(([left], [right]) => left.localeCompare(right));
|
|
122
|
+
return JSON.stringify({
|
|
123
|
+
annotations: entries(projection.annotations),
|
|
124
|
+
widgets: entries(projection.widgets),
|
|
125
|
+
taskGroups: entries(projection.taskGroups),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function assertLifecycleCompleteness(events, snapshot) {
|
|
129
|
+
const lifecycleEvents = events.filter((event) => /^(?:annotation|widget|task-group)\./u.test(event.type));
|
|
130
|
+
if (lifecycleEvents.length === 0)
|
|
131
|
+
return [];
|
|
132
|
+
const projection = createLifecycleProjection();
|
|
133
|
+
lifecycleEvents.forEach((event) => applyLifecycleEvent(projection, event));
|
|
134
|
+
const once = serializeLifecycleProjection(projection);
|
|
135
|
+
lifecycleEvents.forEach((event) => applyLifecycleEvent(projection, event));
|
|
136
|
+
assert(serializeLifecycleProjection(projection) === once, "Rich lifecycle events must be replay-idempotent.");
|
|
137
|
+
if (!snapshot)
|
|
138
|
+
return ["rich lifecycle replay"];
|
|
139
|
+
const projected = createLifecycleProjection();
|
|
140
|
+
for (const entry of snapshot.annotations ?? []) {
|
|
141
|
+
projected.annotations.set(entry.annotation.id, {
|
|
142
|
+
messageId: entry.messageId,
|
|
143
|
+
value: entry.annotation,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
for (const entry of snapshot.widgets ?? []) {
|
|
147
|
+
projected.widgets.set(entry.widget.id, {
|
|
148
|
+
messageId: entry.messageId,
|
|
149
|
+
value: entry.widget,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
for (const taskGroup of snapshot.taskGroups ?? []) {
|
|
153
|
+
projected.taskGroups.set(taskGroup.id, taskGroup);
|
|
154
|
+
}
|
|
155
|
+
assert(serializeLifecycleProjection(projected) === once, "Thread snapshot lifecycle projections must match replayed canonical events.");
|
|
156
|
+
return ["rich lifecycle replay", "rich lifecycle snapshot"];
|
|
157
|
+
}
|
|
158
|
+
/** Key order is not part of a JSON value, so comparison must not depend on it. */
|
|
159
|
+
function canonicalJson(value) {
|
|
160
|
+
return JSON.stringify(value, (_key, nested) => {
|
|
161
|
+
if (typeof nested !== "object" ||
|
|
162
|
+
nested === null ||
|
|
163
|
+
Array.isArray(nested)) {
|
|
164
|
+
return nested;
|
|
165
|
+
}
|
|
166
|
+
return Object.fromEntries(Object.entries(nested).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Every domain event must survive the AG-UI wire and come back identical, and
|
|
171
|
+
* the frame in between must be one a stock AG-UI client can parse. Checking
|
|
172
|
+
* both together is the point: a profile that round-trips through frames only
|
|
173
|
+
* Builder can read would pass the first half and deliver no interoperability.
|
|
174
|
+
*/
|
|
175
|
+
function assertAgUiWireCompatibility(events, context) {
|
|
176
|
+
for (const event of events) {
|
|
177
|
+
const frame = encodeAgentEvent(event);
|
|
178
|
+
assert(isAgUiEvent(frame), `Event ${event.type} did not encode to a valid AG-UI event.`);
|
|
179
|
+
const decoded = decodeAgUiEvent(frame, context);
|
|
180
|
+
assert(decoded !== undefined, `Event ${event.type} did not decode back from its AG-UI frame.`);
|
|
181
|
+
assert(canonicalJson(decoded) === canonicalJson(event), `Event ${event.type} changed while round-tripping through AG-UI: ${canonicalJson(event)} became ${canonicalJson(decoded)}.`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function assertOrderedEvents(input) {
|
|
185
|
+
const { events, threadId, runId, afterSequence = 0, requireStarted = afterSequence === 0, terminal = "any", } = input;
|
|
186
|
+
assert(events.length > 0, "A run stream must emit at least one event.");
|
|
187
|
+
parseAgentEventSequence(events, {
|
|
188
|
+
afterSequence,
|
|
189
|
+
threadId,
|
|
190
|
+
runId,
|
|
191
|
+
path: "runEvents",
|
|
192
|
+
});
|
|
193
|
+
assertAgUiWireCompatibility(events, { threadId, runId });
|
|
194
|
+
if (requireStarted) {
|
|
195
|
+
assert(events[0]?.type === "run.started", "The first event must be run.started.");
|
|
196
|
+
}
|
|
197
|
+
const ids = new Set();
|
|
198
|
+
events.forEach((event, index) => {
|
|
199
|
+
const expectedSequence = afterSequence + index + 1;
|
|
200
|
+
assert(event.sequence === expectedSequence, `Event sequences must be contiguous; expected ${expectedSequence}, received ${event.sequence}.`);
|
|
201
|
+
assert(event.threadId === threadId, "Every event must preserve threadId.");
|
|
202
|
+
assert(event.runId === runId, "Every event must preserve the runId returned by startRun.");
|
|
203
|
+
assert(!ids.has(event.id), `Event id ${event.id} was emitted more than once.`);
|
|
204
|
+
ids.add(event.id);
|
|
205
|
+
if (isTerminal(event)) {
|
|
206
|
+
assert(index === events.length - 1, "No event may be emitted after a terminal event.");
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
const finalEvent = events.at(-1);
|
|
210
|
+
if (terminal === "none") {
|
|
211
|
+
assert(!isTerminal(finalEvent), "An interrupted stream must not invent a terminal event.");
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
assert(isTerminal(finalEvent), "A closed run stream must end with an explicit terminal event.");
|
|
215
|
+
if (terminal !== "any") {
|
|
216
|
+
assert(finalEvent.type === terminal, `Expected ${terminal}, received ${finalEvent.type}.`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function readCapabilities(transport) {
|
|
220
|
+
const staticCapabilities = transport.capabilities
|
|
221
|
+
? parseAgentCapabilities(transport.capabilities)
|
|
222
|
+
: undefined;
|
|
223
|
+
const discovered = transport.discoverCapabilities
|
|
224
|
+
? await transport.discoverCapabilities({
|
|
225
|
+
protocol: createAgentKitProtocolVersionOffer(),
|
|
226
|
+
})
|
|
227
|
+
: undefined;
|
|
228
|
+
const capabilities = discovered
|
|
229
|
+
? parseAgentCapabilities(discovered.legacy ?? projectAgentCapabilities(discovered))
|
|
230
|
+
: (staticCapabilities ?? {});
|
|
231
|
+
if (staticCapabilities && discovered) {
|
|
232
|
+
for (const [id, value] of Object.entries(staticCapabilities)) {
|
|
233
|
+
assert(capabilities[id] === value, `Static and discovered capabilities must not contradict one another for ${JSON.stringify(id)}.`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
assert(capabilities.protocolVersion === undefined ||
|
|
237
|
+
capabilities.protocolVersion === AGENTKIT_PROTOCOL_VERSION, `Transport protocolVersion must be ${AGENTKIT_PROTOCOL_VERSION}.`);
|
|
238
|
+
return capabilities;
|
|
239
|
+
}
|
|
240
|
+
function assertCapabilitiesTruthful(transport, capabilities) {
|
|
241
|
+
const requireOperation = (capability, operation, label) => {
|
|
242
|
+
if (capability) {
|
|
243
|
+
assert(typeof operation === "function", `${label} is advertised but its transport operation is missing.`);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
requireOperation(capabilities.actions, transport.invokeAction, "actions");
|
|
247
|
+
if (capabilities.approvals) {
|
|
248
|
+
const operation = capabilities.protocolVersion === AGENTKIT_PROTOCOL_VERSION
|
|
249
|
+
? transport.resumeRun
|
|
250
|
+
: (transport.resumeRun ?? transport.resolveApproval);
|
|
251
|
+
requireOperation(true, operation, "approvals");
|
|
252
|
+
}
|
|
253
|
+
requireOperation(capabilities.feedback, transport.submitFeedback, "feedback");
|
|
254
|
+
requireOperation(capabilities.threadHistory, transport.getThreadSnapshot, "threadHistory");
|
|
255
|
+
requireOperation(capabilities.threadForking, transport.forkThread, "threadForking");
|
|
256
|
+
requireOperation(capabilities.resumableRuns, transport.getRun, "resumableRuns");
|
|
257
|
+
if (capabilities.messageQueue) {
|
|
258
|
+
for (const [name, operation] of [
|
|
259
|
+
["listQueuedMessages", transport.listQueuedMessages],
|
|
260
|
+
["queueMessage", transport.queueMessage],
|
|
261
|
+
["steerQueuedMessage", transport.steerQueuedMessage],
|
|
262
|
+
["removeQueuedMessage", transport.removeQueuedMessage],
|
|
263
|
+
]) {
|
|
264
|
+
assert(typeof operation === "function", `messageQueue is advertised but ${name} is missing.`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (capabilities.uploads) {
|
|
268
|
+
for (const [name, operation] of [
|
|
269
|
+
["createUpload", transport.createUpload],
|
|
270
|
+
["completeUpload", transport.completeUpload],
|
|
271
|
+
["cancelUpload", transport.cancelUpload],
|
|
272
|
+
]) {
|
|
273
|
+
assert(typeof operation === "function", `uploads is advertised but ${name} is missing.`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
async function assertUnsupportedOperations(transport, capabilities, isUnsupportedError, threadId) {
|
|
278
|
+
const probe = async (label, operation) => {
|
|
279
|
+
if (!operation)
|
|
280
|
+
return;
|
|
281
|
+
let failure;
|
|
282
|
+
try {
|
|
283
|
+
await operation();
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
failure = error;
|
|
287
|
+
}
|
|
288
|
+
assert(failure !== undefined, `${label} must reject when it is not advertised.`);
|
|
289
|
+
assert(isUnsupportedError(failure), `${label} must reject with a recognizable unsupported-capability error.`);
|
|
290
|
+
};
|
|
291
|
+
if (!capabilities.actions) {
|
|
292
|
+
await probe("invokeAction", transport.invokeAction
|
|
293
|
+
? () => transport.invokeAction({
|
|
294
|
+
invocation: {
|
|
295
|
+
id: "conformance-action",
|
|
296
|
+
action: "conformance.unsupported",
|
|
297
|
+
threadId,
|
|
298
|
+
},
|
|
299
|
+
})
|
|
300
|
+
: undefined);
|
|
301
|
+
}
|
|
302
|
+
if (!capabilities.approvals) {
|
|
303
|
+
await probe("resumeRun", transport.resumeRun
|
|
304
|
+
? () => transport.resumeRun({
|
|
305
|
+
threadId,
|
|
306
|
+
runId: "conformance-unsupported-run",
|
|
307
|
+
resume: [
|
|
308
|
+
{
|
|
309
|
+
interruptId: "conformance-unsupported-approval",
|
|
310
|
+
status: "resolved",
|
|
311
|
+
payload: { decision: "approve" },
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
})
|
|
315
|
+
: undefined);
|
|
316
|
+
}
|
|
317
|
+
if (!capabilities.feedback) {
|
|
318
|
+
await probe("submitFeedback", transport.submitFeedback
|
|
319
|
+
? () => transport.submitFeedback({
|
|
320
|
+
threadId,
|
|
321
|
+
messageId: "conformance-unsupported-message",
|
|
322
|
+
value: "dismissed",
|
|
323
|
+
})
|
|
324
|
+
: undefined);
|
|
325
|
+
}
|
|
326
|
+
if (!capabilities.threadHistory) {
|
|
327
|
+
await probe("getThreadSnapshot", transport.getThreadSnapshot
|
|
328
|
+
? () => transport.getThreadSnapshot({ threadId })
|
|
329
|
+
: undefined);
|
|
330
|
+
}
|
|
331
|
+
if (!capabilities.threadForking) {
|
|
332
|
+
await probe("forkThread", transport.forkThread
|
|
333
|
+
? () => transport.forkThread({ threadId })
|
|
334
|
+
: undefined);
|
|
335
|
+
}
|
|
336
|
+
if (!capabilities.messageQueue) {
|
|
337
|
+
await probe("queueMessage", transport.queueMessage
|
|
338
|
+
? () => transport.queueMessage({ threadId, text: "unsupported" })
|
|
339
|
+
: undefined);
|
|
340
|
+
}
|
|
341
|
+
if (!capabilities.uploads) {
|
|
342
|
+
await probe("createUpload", transport.createUpload
|
|
343
|
+
? () => transport.createUpload({
|
|
344
|
+
threadId,
|
|
345
|
+
descriptor: {
|
|
346
|
+
name: "unsupported.txt",
|
|
347
|
+
mediaType: "text/plain",
|
|
348
|
+
size: 0,
|
|
349
|
+
},
|
|
350
|
+
})
|
|
351
|
+
: undefined);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
function conformanceMessages(scenario, messages) {
|
|
355
|
+
if (scenario === "completed" && messages)
|
|
356
|
+
return messages;
|
|
357
|
+
return [
|
|
358
|
+
{
|
|
359
|
+
id: `agentkit-conformance-${scenario}-message`,
|
|
360
|
+
role: "user",
|
|
361
|
+
parts: [
|
|
362
|
+
{
|
|
363
|
+
type: "text",
|
|
364
|
+
text: `Execute the deterministic AgentKit ${scenario} scenario.`,
|
|
365
|
+
},
|
|
366
|
+
],
|
|
367
|
+
metadata: { "x-agentkit-conformance-scenario": scenario },
|
|
368
|
+
},
|
|
369
|
+
];
|
|
370
|
+
}
|
|
371
|
+
async function startScenarioRun(input) {
|
|
372
|
+
const started = await input.transport.startRun({
|
|
373
|
+
threadId: input.threadId,
|
|
374
|
+
messages: conformanceMessages(input.scenario, input.messages),
|
|
375
|
+
metadata: { "x-agentkit-conformance-scenario": input.scenario },
|
|
376
|
+
});
|
|
377
|
+
assert(started.runId.length > 0, "startRun must return a stable runId.");
|
|
378
|
+
if (started.capabilities)
|
|
379
|
+
parseAgentCapabilities(started.capabilities);
|
|
380
|
+
return started;
|
|
381
|
+
}
|
|
382
|
+
async function assertRunSnapshot(transport, threadId, runId, terminal) {
|
|
383
|
+
if (!transport.getRun)
|
|
384
|
+
return;
|
|
385
|
+
const snapshot = await transport.getRun({ threadId, runId });
|
|
386
|
+
assert(snapshot !== null, "getRun must return a started run.");
|
|
387
|
+
assert(snapshot.lastSequence === terminal.sequence, "getRun.lastSequence must match the durable event log.");
|
|
388
|
+
assert(snapshot.status === terminalStatus(terminal), "getRun.status must match the terminal event.");
|
|
389
|
+
if (terminal.type === "run.failed") {
|
|
390
|
+
assert(snapshot.error !== undefined, "A failed run snapshot must preserve its error.");
|
|
391
|
+
assert(snapshot.error.code === terminal.error.code, "A failed run snapshot must preserve the terminal error code.");
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
async function assertBaseline(input) {
|
|
395
|
+
const started = await startScenarioRun({
|
|
396
|
+
transport: input.transport,
|
|
397
|
+
scenario: "completed",
|
|
398
|
+
threadId: input.threadId,
|
|
399
|
+
messages: input.messages,
|
|
400
|
+
});
|
|
401
|
+
const events = await collect(input.transport.subscribeToRun({
|
|
402
|
+
threadId: input.threadId,
|
|
403
|
+
runId: started.runId,
|
|
404
|
+
}), input.timeoutMs, "completed run stream");
|
|
405
|
+
assertOrderedEvents({
|
|
406
|
+
events,
|
|
407
|
+
threadId: input.threadId,
|
|
408
|
+
runId: started.runId,
|
|
409
|
+
terminal: "run.completed",
|
|
410
|
+
});
|
|
411
|
+
assertTerminalActivities(events);
|
|
412
|
+
const terminal = events.at(-1);
|
|
413
|
+
let replay = [];
|
|
414
|
+
if (input.capabilities.resumableRuns) {
|
|
415
|
+
const cursor = Math.max(0, Math.floor(events.length / 2));
|
|
416
|
+
replay = await collect(input.transport.subscribeToRun({
|
|
417
|
+
threadId: input.threadId,
|
|
418
|
+
runId: started.runId,
|
|
419
|
+
afterSequence: cursor,
|
|
420
|
+
}), input.timeoutMs, "completed run replay");
|
|
421
|
+
assertOrderedEvents({
|
|
422
|
+
events: replay,
|
|
423
|
+
threadId: input.threadId,
|
|
424
|
+
runId: started.runId,
|
|
425
|
+
afterSequence: cursor,
|
|
426
|
+
requireStarted: false,
|
|
427
|
+
terminal: "run.completed",
|
|
428
|
+
});
|
|
429
|
+
assert(replay.length === events.length - cursor, "A resumable subscription must replay every event after its cursor.");
|
|
430
|
+
assert(replay.every((event, index) => event.id === events[cursor + index]?.id), "Replay must preserve event identity and order.");
|
|
431
|
+
}
|
|
432
|
+
await assertRunSnapshot(input.transport, input.threadId, started.runId, terminal);
|
|
433
|
+
let snapshot;
|
|
434
|
+
if (input.transport.getThreadSnapshot) {
|
|
435
|
+
const result = await input.transport.getThreadSnapshot({
|
|
436
|
+
threadId: input.threadId,
|
|
437
|
+
});
|
|
438
|
+
assert(result !== null, "getThreadSnapshot must return the active thread.");
|
|
439
|
+
snapshot = parseAgentThreadSnapshot(result);
|
|
440
|
+
assert(snapshot.id === input.threadId, "getThreadSnapshot must preserve the requested thread id.");
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
runId: started.runId,
|
|
444
|
+
events,
|
|
445
|
+
replay,
|
|
446
|
+
lifecycleChecks: assertLifecycleCompleteness(events, snapshot),
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
function sameCapabilities(baseline, scenario, name) {
|
|
450
|
+
assert(JSON.stringify(baseline) === JSON.stringify(scenario), `Capabilities changed while preparing the ${name} scenario.`);
|
|
451
|
+
}
|
|
452
|
+
async function assertCancellation(input) {
|
|
453
|
+
const started = await startScenarioRun({
|
|
454
|
+
...input,
|
|
455
|
+
scenario: "cancellation",
|
|
456
|
+
});
|
|
457
|
+
const iterator = input.transport
|
|
458
|
+
.subscribeToRun({ threadId: input.threadId, runId: started.runId })[Symbol.asyncIterator]();
|
|
459
|
+
const first = await withTimeout(iterator.next(), input.timeoutMs, "cancellation run start");
|
|
460
|
+
assert(!first.done, "A cancellable run must start before it is cancelled.");
|
|
461
|
+
const startedEvent = parseAgentEvent(first.value);
|
|
462
|
+
assertOrderedEvents({
|
|
463
|
+
events: [startedEvent],
|
|
464
|
+
threadId: input.threadId,
|
|
465
|
+
runId: started.runId,
|
|
466
|
+
terminal: "none",
|
|
467
|
+
});
|
|
468
|
+
await input.transport.cancelRun({
|
|
469
|
+
threadId: input.threadId,
|
|
470
|
+
runId: started.runId,
|
|
471
|
+
});
|
|
472
|
+
const events = [
|
|
473
|
+
startedEvent,
|
|
474
|
+
...(await collectIterator(iterator, input.timeoutMs, "cancelled run terminal event")),
|
|
475
|
+
];
|
|
476
|
+
assertOrderedEvents({
|
|
477
|
+
events,
|
|
478
|
+
threadId: input.threadId,
|
|
479
|
+
runId: started.runId,
|
|
480
|
+
terminal: "run.cancelled",
|
|
481
|
+
});
|
|
482
|
+
await assertRunSnapshot(input.transport, input.threadId, started.runId, events.at(-1));
|
|
483
|
+
return events.length;
|
|
484
|
+
}
|
|
485
|
+
function isAbortError(error) {
|
|
486
|
+
return error instanceof Error && error.name === "AbortError";
|
|
487
|
+
}
|
|
488
|
+
async function assertAbortDisconnect(input) {
|
|
489
|
+
const started = await startScenarioRun({ ...input, scenario: "abort" });
|
|
490
|
+
const controller = new AbortController();
|
|
491
|
+
const iterator = input.transport
|
|
492
|
+
.subscribeToRun({
|
|
493
|
+
threadId: input.threadId,
|
|
494
|
+
runId: started.runId,
|
|
495
|
+
signal: controller.signal,
|
|
496
|
+
})[Symbol.asyncIterator]();
|
|
497
|
+
const first = await withTimeout(iterator.next(), input.timeoutMs, "abort scenario first event");
|
|
498
|
+
assert(!first.done, "An abort scenario must emit run.started before disconnect.");
|
|
499
|
+
const startedEvent = parseAgentEvent(first.value);
|
|
500
|
+
controller.abort(new DOMException("Conformance disconnect", "AbortError"));
|
|
501
|
+
let afterAbort;
|
|
502
|
+
try {
|
|
503
|
+
afterAbort = await withTimeout(iterator.next(), input.timeoutMs, "aborted subscription");
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
assert(isAbortError(error), "An aborted subscription may only close or reject with AbortError.");
|
|
507
|
+
}
|
|
508
|
+
finally {
|
|
509
|
+
await iterator.return?.();
|
|
510
|
+
}
|
|
511
|
+
if (afterAbort) {
|
|
512
|
+
assert(afterAbort.done, "Aborting a subscriber must stop event delivery without cancelling the run.");
|
|
513
|
+
}
|
|
514
|
+
if (!input.capabilities.resumableRuns) {
|
|
515
|
+
if (input.transport.getRun) {
|
|
516
|
+
const snapshot = await input.transport.getRun({
|
|
517
|
+
threadId: input.threadId,
|
|
518
|
+
runId: started.runId,
|
|
519
|
+
});
|
|
520
|
+
assert(snapshot?.status !== "cancelled", "Aborting a subscriber must not cancel the remote run.");
|
|
521
|
+
}
|
|
522
|
+
return 1;
|
|
523
|
+
}
|
|
524
|
+
const remainder = await collect(input.transport.subscribeToRun({
|
|
525
|
+
threadId: input.threadId,
|
|
526
|
+
runId: started.runId,
|
|
527
|
+
afterSequence: startedEvent.sequence,
|
|
528
|
+
}), input.timeoutMs, "post-abort replay");
|
|
529
|
+
assertOrderedEvents({
|
|
530
|
+
events: remainder,
|
|
531
|
+
threadId: input.threadId,
|
|
532
|
+
runId: started.runId,
|
|
533
|
+
afterSequence: startedEvent.sequence,
|
|
534
|
+
requireStarted: false,
|
|
535
|
+
terminal: "run.completed",
|
|
536
|
+
});
|
|
537
|
+
return 1 + remainder.length;
|
|
538
|
+
}
|
|
539
|
+
async function assertReconnectInterruption(input) {
|
|
540
|
+
const started = await startScenarioRun({ ...input, scenario: "reconnect" });
|
|
541
|
+
const interrupted = [];
|
|
542
|
+
let interruption;
|
|
543
|
+
try {
|
|
544
|
+
const iterator = input.transport
|
|
545
|
+
.subscribeToRun({
|
|
546
|
+
threadId: input.threadId,
|
|
547
|
+
runId: started.runId,
|
|
548
|
+
})[Symbol.asyncIterator]();
|
|
549
|
+
await withTimeout((async () => {
|
|
550
|
+
while (true) {
|
|
551
|
+
const next = await iterator.next();
|
|
552
|
+
if (next.done)
|
|
553
|
+
return;
|
|
554
|
+
interrupted.push(parseAgentEvent(next.value));
|
|
555
|
+
}
|
|
556
|
+
})(), input.timeoutMs, "interrupted subscription");
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
interruption = error;
|
|
560
|
+
}
|
|
561
|
+
assert(interruption !== undefined, "The reconnect fixture must interrupt its first stream.");
|
|
562
|
+
assertOrderedEvents({
|
|
563
|
+
events: interrupted,
|
|
564
|
+
threadId: input.threadId,
|
|
565
|
+
runId: started.runId,
|
|
566
|
+
terminal: "none",
|
|
567
|
+
});
|
|
568
|
+
const cursor = interrupted.at(-1)?.sequence ?? 0;
|
|
569
|
+
const replay = await collect(input.transport.subscribeToRun({
|
|
570
|
+
threadId: input.threadId,
|
|
571
|
+
runId: started.runId,
|
|
572
|
+
afterSequence: cursor,
|
|
573
|
+
}), input.timeoutMs, "reconnect replay");
|
|
574
|
+
assertOrderedEvents({
|
|
575
|
+
events: replay,
|
|
576
|
+
threadId: input.threadId,
|
|
577
|
+
runId: started.runId,
|
|
578
|
+
afterSequence: cursor,
|
|
579
|
+
requireStarted: false,
|
|
580
|
+
terminal: "run.completed",
|
|
581
|
+
});
|
|
582
|
+
const combined = [...interrupted, ...replay];
|
|
583
|
+
assertOrderedEvents({
|
|
584
|
+
events: combined,
|
|
585
|
+
threadId: input.threadId,
|
|
586
|
+
runId: started.runId,
|
|
587
|
+
terminal: "run.completed",
|
|
588
|
+
});
|
|
589
|
+
return combined.length;
|
|
590
|
+
}
|
|
591
|
+
async function assertCrossThreadIsolation(input) {
|
|
592
|
+
const threadA = `${input.threadId}-a`;
|
|
593
|
+
const threadB = `${input.threadId}-b`;
|
|
594
|
+
const [runA, runB] = await Promise.all([
|
|
595
|
+
startScenarioRun({
|
|
596
|
+
transport: input.transport,
|
|
597
|
+
scenario: "cross-thread",
|
|
598
|
+
threadId: threadA,
|
|
599
|
+
}),
|
|
600
|
+
startScenarioRun({
|
|
601
|
+
transport: input.transport,
|
|
602
|
+
scenario: "cross-thread",
|
|
603
|
+
threadId: threadB,
|
|
604
|
+
}),
|
|
605
|
+
]);
|
|
606
|
+
assert(runA.runId !== runB.runId, "Concurrent threads must receive distinct run ids.");
|
|
607
|
+
const [eventsA, eventsB] = await Promise.all([
|
|
608
|
+
collect(input.transport.subscribeToRun({ threadId: threadA, runId: runA.runId }), input.timeoutMs, "thread A stream"),
|
|
609
|
+
collect(input.transport.subscribeToRun({ threadId: threadB, runId: runB.runId }), input.timeoutMs, "thread B stream"),
|
|
610
|
+
]);
|
|
611
|
+
assertOrderedEvents({
|
|
612
|
+
events: eventsA,
|
|
613
|
+
threadId: threadA,
|
|
614
|
+
runId: runA.runId,
|
|
615
|
+
terminal: "run.completed",
|
|
616
|
+
});
|
|
617
|
+
assertOrderedEvents({
|
|
618
|
+
events: eventsB,
|
|
619
|
+
threadId: threadB,
|
|
620
|
+
runId: runB.runId,
|
|
621
|
+
terminal: "run.completed",
|
|
622
|
+
});
|
|
623
|
+
const controller = new AbortController();
|
|
624
|
+
const leakProbe = (async () => {
|
|
625
|
+
const leaked = [];
|
|
626
|
+
try {
|
|
627
|
+
for await (const event of input.transport.subscribeToRun({
|
|
628
|
+
threadId: threadB,
|
|
629
|
+
runId: runA.runId,
|
|
630
|
+
signal: controller.signal,
|
|
631
|
+
})) {
|
|
632
|
+
leaked.push(parseAgentEvent(event));
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
catch (error) {
|
|
637
|
+
if (!isAbortError(error) &&
|
|
638
|
+
!/unknown|not found|thread/iu.test(String(error))) {
|
|
639
|
+
throw error;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return leaked;
|
|
643
|
+
})();
|
|
644
|
+
const abortTimer = setTimeout(() => controller.abort(), 25);
|
|
645
|
+
const leaked = await withTimeout(leakProbe, input.timeoutMs, "cross-thread subscription rejection").finally(() => clearTimeout(abortTimer));
|
|
646
|
+
assert(leaked.length === 0, "A run must never emit events to a different thread.");
|
|
647
|
+
if (input.transport.getRun) {
|
|
648
|
+
let wrongThreadSnapshot;
|
|
649
|
+
try {
|
|
650
|
+
wrongThreadSnapshot = await input.transport.getRun({
|
|
651
|
+
threadId: threadB,
|
|
652
|
+
runId: runA.runId,
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
wrongThreadSnapshot = null;
|
|
657
|
+
}
|
|
658
|
+
assert(wrongThreadSnapshot === null, "getRun must not expose a run through a different thread id.");
|
|
659
|
+
}
|
|
660
|
+
return eventsA.length + eventsB.length;
|
|
661
|
+
}
|
|
662
|
+
async function assertApprovalContinuation(input) {
|
|
663
|
+
assert(input.transport.resumeRun || input.transport.resolveApproval, "approvals requires resumeRun or the legacy resolveApproval bridge.");
|
|
664
|
+
const started = await startScenarioRun({ ...input, scenario: "approval" });
|
|
665
|
+
const iterator = input.transport
|
|
666
|
+
.subscribeToRun({ threadId: input.threadId, runId: started.runId })[Symbol.asyncIterator]();
|
|
667
|
+
const events = [];
|
|
668
|
+
let requested;
|
|
669
|
+
while (!requested) {
|
|
670
|
+
const next = await withTimeout(iterator.next(), input.timeoutMs, "approval request");
|
|
671
|
+
assert(!next.done, "Approval streams must not close before approval.requested.");
|
|
672
|
+
const event = parseAgentEvent(next.value);
|
|
673
|
+
events.push(event);
|
|
674
|
+
assert(!isTerminal(event), "Approval must be requested before a terminal event.");
|
|
675
|
+
if (event.type === "approval.requested")
|
|
676
|
+
requested = event;
|
|
677
|
+
}
|
|
678
|
+
const approval = {
|
|
679
|
+
approvalId: requested.request.id,
|
|
680
|
+
optionId: "approve",
|
|
681
|
+
response: { decision: "approve", optionIds: ["approve"] },
|
|
682
|
+
};
|
|
683
|
+
const resumed = input.transport.resumeRun
|
|
684
|
+
? await input.transport.resumeRun({
|
|
685
|
+
threadId: input.threadId,
|
|
686
|
+
runId: started.runId,
|
|
687
|
+
resume: [resumeEntryFromApproval(approval)],
|
|
688
|
+
})
|
|
689
|
+
: await input.transport.resolveApproval({
|
|
690
|
+
threadId: input.threadId,
|
|
691
|
+
runId: started.runId,
|
|
692
|
+
...approval,
|
|
693
|
+
}).then(() => ({ runId: started.runId }));
|
|
694
|
+
let continuationEvents;
|
|
695
|
+
if (resumed.runId === started.runId) {
|
|
696
|
+
continuationEvents = await collectIterator(iterator, input.timeoutMs, "approval continuation");
|
|
697
|
+
events.push(...continuationEvents);
|
|
698
|
+
assertOrderedEvents({
|
|
699
|
+
events,
|
|
700
|
+
threadId: input.threadId,
|
|
701
|
+
runId: started.runId,
|
|
702
|
+
terminal: "run.completed",
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
else {
|
|
706
|
+
const closed = await withTimeout(iterator.next(), input.timeoutMs, "interrupted approval stream closure");
|
|
707
|
+
assert(closed.done, "An interrupted run must close before its replacement starts.");
|
|
708
|
+
assertOrderedEvents({
|
|
709
|
+
events,
|
|
710
|
+
threadId: input.threadId,
|
|
711
|
+
runId: started.runId,
|
|
712
|
+
terminal: "none",
|
|
713
|
+
});
|
|
714
|
+
continuationEvents = await collect(input.transport.subscribeToRun({
|
|
715
|
+
threadId: input.threadId,
|
|
716
|
+
runId: resumed.runId,
|
|
717
|
+
}), input.timeoutMs, "approval replacement run");
|
|
718
|
+
assertOrderedEvents({
|
|
719
|
+
events: continuationEvents,
|
|
720
|
+
threadId: input.threadId,
|
|
721
|
+
runId: resumed.runId,
|
|
722
|
+
terminal: "run.completed",
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
const lifecycleEvents = resumed.runId === started.runId
|
|
726
|
+
? events
|
|
727
|
+
: [...events, ...continuationEvents];
|
|
728
|
+
const resolvedIndex = lifecycleEvents.findIndex((event) => event.type === "approval.resolved" &&
|
|
729
|
+
event.approvalId === requested?.request.id);
|
|
730
|
+
assert(resolvedIndex > lifecycleEvents.indexOf(requested), "Approval continuation must emit approval.resolved after approval.requested.");
|
|
731
|
+
const resolved = lifecycleEvents[resolvedIndex];
|
|
732
|
+
assert(resolved?.type === "approval.resolved" &&
|
|
733
|
+
resolved.response.decision === "approve", "Approval continuation must preserve the explicit provider-neutral decision.");
|
|
734
|
+
return lifecycleEvents.length;
|
|
735
|
+
}
|
|
736
|
+
async function assertQueueIdentity(input) {
|
|
737
|
+
const { transport, threadId } = input;
|
|
738
|
+
assert(transport.queueMessage &&
|
|
739
|
+
transport.listQueuedMessages &&
|
|
740
|
+
transport.steerQueuedMessage, "messageQueue requires queue, list, and steer operations.");
|
|
741
|
+
const queued = parseAgentQueuedMessage((await transport.queueMessage({
|
|
742
|
+
threadId,
|
|
743
|
+
text: "Promote this exact queued message.",
|
|
744
|
+
metadata: { "x-agentkit-conformance": true },
|
|
745
|
+
})).message);
|
|
746
|
+
assert(queued.threadId === threadId, "Queued messages must preserve thread identity.");
|
|
747
|
+
assert(queued.id.length > 0, "Queued messages must receive a stable id.");
|
|
748
|
+
const before = await transport.listQueuedMessages({ threadId });
|
|
749
|
+
assert(before.some((message) => message.id === queued.id), "Queue listing must preserve the id returned by queueMessage.");
|
|
750
|
+
let wrongIdentityRejected = false;
|
|
751
|
+
try {
|
|
752
|
+
await transport.steerQueuedMessage({
|
|
753
|
+
threadId,
|
|
754
|
+
messageId: `${queued.id}-unknown`,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
catch {
|
|
758
|
+
wrongIdentityRejected = true;
|
|
759
|
+
}
|
|
760
|
+
assert(wrongIdentityRejected, "Queue steering must reject an unknown message id.");
|
|
761
|
+
assert((await transport.listQueuedMessages({ threadId })).some((message) => message.id === queued.id), "Rejected steering must leave the original queued item intact.");
|
|
762
|
+
const promoted = await transport.steerQueuedMessage({
|
|
763
|
+
threadId,
|
|
764
|
+
messageId: queued.id,
|
|
765
|
+
});
|
|
766
|
+
assert(!(await transport.listQueuedMessages({ threadId })).some((message) => message.id === queued.id), "Successful steering must remove the exact queued item.");
|
|
767
|
+
if (promoted) {
|
|
768
|
+
const events = await collect(transport.subscribeToRun({ threadId, runId: promoted.runId }), input.timeoutMs, "queue promotion run");
|
|
769
|
+
assertOrderedEvents({
|
|
770
|
+
events,
|
|
771
|
+
threadId,
|
|
772
|
+
runId: promoted.runId,
|
|
773
|
+
terminal: "run.completed",
|
|
774
|
+
});
|
|
775
|
+
const promotedMessages = events.filter((event) => event.type === "message.created" && event.message.id === queued.id);
|
|
776
|
+
assert(promotedMessages.length === 1, "Queue promotion must create exactly one user message with the queued id.");
|
|
777
|
+
return events.length;
|
|
778
|
+
}
|
|
779
|
+
assert(transport.getThreadSnapshot, "Void queue steering requires a thread snapshot.");
|
|
780
|
+
const snapshot = await transport.getThreadSnapshot({ threadId });
|
|
781
|
+
assert(snapshot !== null, "Queue promotion must preserve its thread.");
|
|
782
|
+
assert(snapshot.messages.filter((message) => message.id === queued.id).length ===
|
|
783
|
+
1, "Void queue steering must persist exactly one user message with the queued id.");
|
|
784
|
+
return 0;
|
|
785
|
+
}
|
|
786
|
+
async function assertTerminalFailure(input) {
|
|
787
|
+
const started = await startScenarioRun({
|
|
788
|
+
...input,
|
|
789
|
+
scenario: "terminal-failure",
|
|
790
|
+
});
|
|
791
|
+
const events = await collect(input.transport.subscribeToRun({
|
|
792
|
+
threadId: input.threadId,
|
|
793
|
+
runId: started.runId,
|
|
794
|
+
}), input.timeoutMs, "terminal failure stream");
|
|
795
|
+
assertOrderedEvents({
|
|
796
|
+
events,
|
|
797
|
+
threadId: input.threadId,
|
|
798
|
+
runId: started.runId,
|
|
799
|
+
terminal: "run.failed",
|
|
800
|
+
});
|
|
801
|
+
const terminal = events.at(-1);
|
|
802
|
+
assert(terminal?.type === "run.failed" &&
|
|
803
|
+
terminal.error.code.length > 0 &&
|
|
804
|
+
terminal.error.message.length > 0, "run.failed must preserve a non-empty error code and message.");
|
|
805
|
+
await assertRunSnapshot(input.transport, input.threadId, started.runId, terminal);
|
|
806
|
+
return events.length;
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Runs provider-neutral invariants against an AgentKit transport. Supplying a
|
|
810
|
+
* scenario factory executes cancellation, disconnect, reconnect, isolation,
|
|
811
|
+
* approval, queue, and failure paths against fresh deterministic backends.
|
|
812
|
+
*/
|
|
813
|
+
export async function assertAgentTransportConformance(options) {
|
|
814
|
+
const threadId = options.threadId ?? "agentkit-conformance-thread";
|
|
815
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
816
|
+
const isUnsupportedError = options.isUnsupportedError ?? unsupportedByDefault;
|
|
817
|
+
const profile = options.createTransport ? "full" : "baseline";
|
|
818
|
+
const baselineTransport = options.createTransport
|
|
819
|
+
? await options.createTransport("completed")
|
|
820
|
+
: options.transport;
|
|
821
|
+
const ownsBaselineTransport = options.createTransport !== undefined;
|
|
822
|
+
try {
|
|
823
|
+
const capabilities = await readCapabilities(baselineTransport);
|
|
824
|
+
assertCapabilitiesTruthful(baselineTransport, capabilities);
|
|
825
|
+
await assertUnsupportedOperations(baselineTransport, capabilities, isUnsupportedError, threadId);
|
|
826
|
+
const baseline = await assertBaseline({
|
|
827
|
+
transport: baselineTransport,
|
|
828
|
+
capabilities,
|
|
829
|
+
threadId,
|
|
830
|
+
messages: options.messages,
|
|
831
|
+
timeoutMs,
|
|
832
|
+
});
|
|
833
|
+
const checks = [
|
|
834
|
+
"capabilities truthfulness",
|
|
835
|
+
"unsupported operations",
|
|
836
|
+
"event ordering/deduplication",
|
|
837
|
+
"terminal completion",
|
|
838
|
+
"terminal activity projection",
|
|
839
|
+
"version compatibility",
|
|
840
|
+
...(capabilities.resumableRuns ? ["cursor replay"] : []),
|
|
841
|
+
...(baselineTransport.getRun ? ["run snapshot"] : []),
|
|
842
|
+
...(baselineTransport.getThreadSnapshot ? ["thread snapshot"] : []),
|
|
843
|
+
...baseline.lifecycleChecks,
|
|
844
|
+
];
|
|
845
|
+
let scenarioEventCount = 0;
|
|
846
|
+
if (options.createTransport) {
|
|
847
|
+
const runScenario = async (scenario, assertion) => {
|
|
848
|
+
const transport = await options.createTransport(scenario);
|
|
849
|
+
try {
|
|
850
|
+
const scenarioCapabilities = await readCapabilities(transport);
|
|
851
|
+
sameCapabilities(capabilities, scenarioCapabilities, scenario);
|
|
852
|
+
assertCapabilitiesTruthful(transport, scenarioCapabilities);
|
|
853
|
+
return await assertion(transport);
|
|
854
|
+
}
|
|
855
|
+
finally {
|
|
856
|
+
await transport.dispose?.();
|
|
857
|
+
}
|
|
858
|
+
};
|
|
859
|
+
scenarioEventCount += await runScenario("cancellation", (transport) => assertCancellation({
|
|
860
|
+
transport,
|
|
861
|
+
threadId: `${threadId}-cancellation`,
|
|
862
|
+
timeoutMs,
|
|
863
|
+
}));
|
|
864
|
+
checks.push("cancellation");
|
|
865
|
+
scenarioEventCount += await runScenario("abort", (transport) => assertAbortDisconnect({
|
|
866
|
+
transport,
|
|
867
|
+
capabilities,
|
|
868
|
+
threadId: `${threadId}-abort`,
|
|
869
|
+
timeoutMs,
|
|
870
|
+
}));
|
|
871
|
+
checks.push("abort/disconnect");
|
|
872
|
+
if (capabilities.resumableRuns) {
|
|
873
|
+
scenarioEventCount += await runScenario("reconnect", (transport) => assertReconnectInterruption({
|
|
874
|
+
transport,
|
|
875
|
+
threadId: `${threadId}-reconnect`,
|
|
876
|
+
timeoutMs,
|
|
877
|
+
}));
|
|
878
|
+
checks.push("reconnect interruption");
|
|
879
|
+
}
|
|
880
|
+
scenarioEventCount += await runScenario("cross-thread", (transport) => assertCrossThreadIsolation({
|
|
881
|
+
transport,
|
|
882
|
+
threadId: `${threadId}-isolation`,
|
|
883
|
+
timeoutMs,
|
|
884
|
+
}));
|
|
885
|
+
checks.push("cross-thread isolation");
|
|
886
|
+
if (capabilities.messageQueue) {
|
|
887
|
+
scenarioEventCount += await runScenario("queue", (transport) => assertQueueIdentity({
|
|
888
|
+
transport,
|
|
889
|
+
threadId: `${threadId}-queue`,
|
|
890
|
+
timeoutMs,
|
|
891
|
+
}));
|
|
892
|
+
checks.push("queue identity/steering");
|
|
893
|
+
}
|
|
894
|
+
if (capabilities.approvals) {
|
|
895
|
+
scenarioEventCount += await runScenario("approval", (transport) => assertApprovalContinuation({
|
|
896
|
+
transport,
|
|
897
|
+
threadId: `${threadId}-approval`,
|
|
898
|
+
timeoutMs,
|
|
899
|
+
}));
|
|
900
|
+
checks.push("approval continuation");
|
|
901
|
+
}
|
|
902
|
+
scenarioEventCount += await runScenario("terminal-failure", (transport) => assertTerminalFailure({
|
|
903
|
+
transport,
|
|
904
|
+
threadId: `${threadId}-failure`,
|
|
905
|
+
timeoutMs,
|
|
906
|
+
}));
|
|
907
|
+
checks.push("terminal failure");
|
|
908
|
+
}
|
|
909
|
+
return {
|
|
910
|
+
profile,
|
|
911
|
+
runId: baseline.runId,
|
|
912
|
+
eventCount: baseline.events.length,
|
|
913
|
+
replayedEventCount: baseline.replay.length,
|
|
914
|
+
scenarioEventCount,
|
|
915
|
+
capabilities,
|
|
916
|
+
checks,
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
finally {
|
|
920
|
+
if (ownsBaselineTransport)
|
|
921
|
+
await baselineTransport.dispose?.();
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
//# sourceMappingURL=index.js.map
|