@genkit-ai/ai
Version:
Genkit AI framework generative AI APIs.
1,029 lines (922 loc) • 33.1 kB
text/typescript
/**
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Agent conformance test runner.
*
* Reads the shared spec from tests/specs/agent.yaml and executes each test
* case against harness-provided agent implementations. See
* docs/agents-conformance-testing.md for the full spec format reference and
* harness requirements.
*/
import { stripUndefinedProps, z } from '@genkit-ai/core';
import { initNodeFeatures } from '@genkit-ai/core/node';
import { Registry } from '@genkit-ai/core/registry';
import * as assert from 'assert';
import { readFileSync } from 'fs';
import { beforeEach, describe, it } from 'node:test';
import { parse } from 'yaml';
import {
defineAgent,
defineCustomAgent,
type Agent,
type AgentStreamChunk,
} from '../src/agent.js';
import { InMemorySessionStore } from '../src/session-stores.js';
import { ToolInterruptError, defineTool, interrupt } from '../src/tool.js';
import { defineProgrammableModel, type ProgrammableModel } from './helpers.js';
initNodeFeatures();
// ---------------------------------------------------------------------------
// Spec parsing & validation
// ---------------------------------------------------------------------------
// These Zod schemas mirror the canonical definitions in
// genkit-tools/common/src/types/agents-conformance.ts. We define them
// locally here because js/ai does not depend on genkit-tools/common.
const OutputAssertionsSchema = z.object({
message: z.any().optional(),
hasSnapshotId: z.boolean().optional(),
hasSessionId: z.boolean().optional(),
stateContains: z.any().optional(),
artifactsContain: z.array(z.any()).optional(),
finishReason: z.string().optional(),
errorContains: z.any().optional(),
});
const SnapshotAssertionsSchema = z.object({
parentId: z.string().optional(),
status: z.string().optional(),
finishReason: z.string().optional(),
hasSessionId: z.boolean().optional(),
stateContains: z.any().optional(),
errorContains: z.any().optional(),
});
const SendInvocationSchema = z.object({
type: z.literal('send'),
init: z.any().optional(),
inputs: z.array(z.any()).optional(),
modelResponses: z.array(z.any()).optional(),
streamChunks: z.array(z.array(z.any())).optional(),
expectChunks: z.array(z.any()).optional(),
expectOutput: OutputAssertionsSchema.optional(),
// If present, the turn is expected to throw an error (API misuse) rather than
// resolve with a graceful `finishReason: 'failed'` output. `status` is matched
// exactly and `message` as a substring.
expectError: z
.object({
status: z.string().optional(),
message: z.string().optional(),
})
.optional(),
captureSnapshotId: z.string().optional(),
captureState: z.string().optional(),
captureSessionId: z.string().optional(),
});
const GetSnapshotDataInvocationSchema = z.object({
type: z.literal('getSnapshotData'),
// Exactly one of snapshotId / sessionId. sessionId resolves the session's
// latest (leaf) snapshot.
snapshotId: z.string().optional(),
sessionId: z.string().optional(),
expectSnapshot: SnapshotAssertionsSchema.optional(),
// If present, the lookup is expected to throw an error containing this text
// (e.g. branching sessions reject sessionId lookups).
expectError: z.string().optional(),
});
const AbortInvocationSchema = z.object({
type: z.literal('abort'),
snapshotId: z.string(),
// YAML `~` (null) means "expect undefined/absent". A string value means
// "expect exactly this status".
expectPreviousStatus: z.string().nullable().optional(),
});
const WaitUntilCompletedInvocationSchema = z.object({
type: z.literal('waitUntilCompleted'),
snapshotId: z.string(),
timeoutMs: z.number().optional(),
expectSnapshot: SnapshotAssertionsSchema.optional(),
});
const SpecStepSchema = z.discriminatedUnion('type', [
SendInvocationSchema,
GetSnapshotDataInvocationSchema,
AbortInvocationSchema,
WaitUntilCompletedInvocationSchema,
]);
const SpecTestSchema = z.object({
name: z.string(),
description: z.string().optional(),
agent: z.string(),
steps: z.array(SpecStepSchema),
});
const SpecSuiteSchema = z.object({
tests: z.array(SpecTestSchema),
});
type SendInvocation = z.infer<typeof SendInvocationSchema>;
type SpecStep = z.infer<typeof SpecStepSchema>;
// ---------------------------------------------------------------------------
// Template resolution
// ---------------------------------------------------------------------------
/**
* Recursively resolves `{{name}}` template references in any value using the
* provided captures map. Only simple `{{name}}` syntax is supported.
*/
function resolveTemplates(value: any, captures: Map<string, any>): any {
if (typeof value === 'string') {
const match = value.match(/^\{\{(\w+)\}\}$/);
if (match) {
const name = match[1];
if (!captures.has(name)) {
throw new Error(
`Template reference '{{${name}}}' not found in captures`
);
}
return captures.get(name);
}
// Also handle inline template replacements (partial string)
return value.replace(/\{\{(\w+)\}\}/g, (_, name) => {
if (!captures.has(name)) {
throw new Error(
`Template reference '{{${name}}}' not found in captures`
);
}
const v = captures.get(name);
return typeof v === 'string' ? v : JSON.stringify(v);
});
}
if (Array.isArray(value)) {
return value.map((item) => resolveTemplates(item, captures));
}
if (value && typeof value === 'object') {
const resolved: Record<string, any> = {};
for (const [k, v] of Object.entries(value)) {
resolved[k] = resolveTemplates(v, captures);
}
return resolved;
}
return value;
}
// ---------------------------------------------------------------------------
// "Contains" assertion helpers
// ---------------------------------------------------------------------------
/**
* Asserts that `actual` contains all fields specified in `expected`.
* For arrays (like messages), asserts that all expected items appear in order
* as a contiguous subsequence within the actual array.
*/
function assertContains(actual: any, expected: any, path: string = ''): void {
if (expected === undefined || expected === null) return;
if (Array.isArray(expected)) {
assert.ok(
Array.isArray(actual),
`Expected array at ${path}, got ${typeof actual}`
);
// Find the expected items as a contiguous subsequence in actual
assertContainsSubsequence(actual, expected, path);
return;
}
if (typeof expected === 'object') {
assert.ok(
actual && typeof actual === 'object',
`Expected object at ${path}, got ${typeof actual}`
);
for (const [key, val] of Object.entries(expected)) {
assertContains(actual[key], val, `${path}.${key}`);
}
return;
}
assert.deepStrictEqual(
actual,
expected,
`Mismatch at ${path}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
);
}
/**
* Asserts that all items in `expected` appear in `actual` in order.
* Each expected item is matched against actual items using deep-contains logic.
* Items must appear in the same relative order but need not be contiguous.
*/
function assertContainsSubsequence(
actual: any[],
expected: any[],
path: string
): void {
let actualIdx = 0;
for (let i = 0; i < expected.length; i++) {
let found = false;
while (actualIdx < actual.length) {
try {
assertContains(actual[actualIdx], expected[i], `${path}[${actualIdx}]`);
found = true;
actualIdx++;
break;
} catch {
actualIdx++;
}
}
if (!found) {
assert.fail(
`Expected item at ${path}[${i}] not found in actual array.\n` +
` Expected: ${JSON.stringify(expected[i])}\n` +
` Actual array: ${JSON.stringify(actual)}`
);
}
}
}
// ---------------------------------------------------------------------------
// Strip helpers
// ---------------------------------------------------------------------------
/**
* Recursively strips undefined values from an object/array for clean
* comparison. Note: `null` is preserved — stripping it could mask real
* differences (e.g. a field explicitly set to `null` vs absent).
*/
function deepStrip(value: any): any {
if (value === undefined) return undefined;
if (value === null) return null;
if (Array.isArray(value)) {
return value.map(deepStrip);
}
if (typeof value === 'object') {
const out: Record<string, any> = {};
for (const [k, v] of Object.entries(value)) {
const stripped = deepStrip(v);
if (stripped !== undefined) {
out[k] = stripped;
}
}
return out;
}
return value;
}
// ---------------------------------------------------------------------------
// Harness setup
// ---------------------------------------------------------------------------
interface HarnessAgents {
[name: string]: Agent;
}
function setupHarness(
registry: Registry,
pm: ProgrammableModel
): HarnessAgents {
// --- Tools ---
defineTool(
registry,
{ name: 'testTool', description: 'A simple test tool' },
async () => 'tool called'
);
// interruptTool is registered via the `interrupt()` helper rather than
// `defineTool()` because it uses the interrupt mechanism (pausing execution
// and returning the tool request to the client for external resolution).
// The helper returns an action definition that must be manually registered.
const interruptToolDef = interrupt({
name: 'interruptTool',
description: 'An interrupt tool',
inputSchema: z.object({ query: z.string() }),
outputSchema: z.object({ answer: z.string() }),
});
registry.registerAction('tool', interruptToolDef);
// restartTool is a regular tool that uses ToolInterruptError to pause
// execution on first call, then succeeds on restart when `resumed`
// metadata is provided.
defineTool(
registry,
{
name: 'restartTool',
description: 'A tool that requires confirmation before executing',
inputSchema: z.object({ action: z.string() }),
outputSchema: z.object({ result: z.string() }),
},
async (input, { resumed }) => {
if (!resumed) {
throw new ToolInterruptError({ requiresConfirmation: true });
}
return { result: `confirmed: ${input.action}` };
}
);
// --- Agents ---
// promptAgent: client-managed, no tools
const promptAgent = defineAgent(registry, {
name: 'promptAgent',
model: 'programmableModel',
config: { temperature: 1 },
});
// promptAgentWithStore: server-managed
const promptAgentWithStore = defineAgent(registry, {
name: 'promptAgentWithStore',
model: 'programmableModel',
config: { temperature: 1 },
store: new InMemorySessionStore(),
});
// promptAgentWithTools: client-managed, with testTool
const promptAgentWithTools = defineAgent(registry, {
name: 'promptAgentWithTools',
model: 'programmableModel',
config: { temperature: 1 },
tools: ['testTool'],
});
// promptAgentWithInterrupt: server-managed, with interruptTool
const promptAgentWithInterrupt = defineAgent(registry, {
name: 'promptAgentWithInterrupt',
model: 'programmableModel',
config: { temperature: 1 },
tools: ['interruptTool'],
store: new InMemorySessionStore(),
});
// promptAgentWithRestartTool: server-managed, with restartTool.
// Used for resume.restart conformance tests.
const promptAgentWithRestartTool = defineAgent(registry, {
name: 'promptAgentWithRestartTool',
model: 'programmableModel',
config: { temperature: 1 },
tools: ['restartTool'],
store: new InMemorySessionStore(),
});
// --- Phase 2: Custom agents for detach, abort, artifacts, state ---
// customAgentBlocking: server-managed, blocks until abort signal fires.
// Used for abort-while-pending tests.
const customAgentBlocking = defineCustomAgent(
registry,
{
name: 'customAgentBlocking',
store: new InMemorySessionStore(),
},
async (sess, { abortSignal }) => {
await sess.run(async () => {
await new Promise<void>((resolve) => {
if (abortSignal?.aborted) {
resolve();
return;
}
abortSignal?.addEventListener('abort', () => resolve(), {
once: true,
});
});
});
return {
message: { role: 'model', content: [{ text: 'unblocked' }] },
};
}
);
// customAgentFailing: server-managed, throws during processing.
// Used for detach + background failure tests.
const customAgentFailing = defineCustomAgent(
registry,
{
name: 'customAgentFailing',
store: new InMemorySessionStore(),
},
async (sess) => {
await sess.run(async () => {
throw new Error('intentional failure');
});
return {
message: { role: 'model', content: [{ text: 'unreachable' }] },
};
}
);
// customAgentWithArtifacts: client-managed, adds and updates artifacts.
const customAgentWithArtifacts = defineCustomAgent(
registry,
{
name: 'customAgentWithArtifacts',
},
async (sess) => {
await sess.run(async () => {
sess.session.addArtifacts([{ name: 'doc1', parts: [{ text: 'v1' }] }]);
sess.session.addArtifacts([{ name: 'doc1', parts: [{ text: 'v2' }] }]);
sess.session.addArtifacts([
{ name: 'doc2', parts: [{ text: 'other' }] },
]);
});
return {
artifacts: sess.session.getArtifacts(),
message: { role: 'model', content: [{ text: 'done' }] },
};
}
);
// customAgentWithCustomState: client-managed, increments a counter on
// each turn. Useful for verifying custom state persistence across
// invocations.
const customAgentWithCustomState = defineCustomAgent(
registry,
{
name: 'customAgentWithCustomState',
},
async (sess) => {
await sess.run(async () => {
const prev = (sess.session.getCustom() as any) || {};
const counter = (prev.counter || 0) + 1;
sess.session.updateCustom(() => ({ counter }));
});
return {
message: { role: 'model', content: [{ text: 'done' }] },
};
}
);
// customAgentWithMultiCustomState: client-managed, performs several
// sequential custom-state updates within a single turn. Used to verify the
// customPatch streaming contract: the first patch of a turn is a
// whole-document replace, and subsequent patches are incremental diffs.
const customAgentWithMultiCustomState = defineCustomAgent(
registry,
{
name: 'customAgentWithMultiCustomState',
},
async (sess) => {
await sess.run(async () => {
sess.session.updateCustom(() => ({ counter: 1, status: 'working' }));
sess.session.updateCustom(
(prev: any) => ({ ...prev, counter: 2 }) as any
);
sess.session.updateCustom(
(prev: any) => ({ ...prev, status: 'done' }) as any
);
});
return {
message: { role: 'model', content: [{ text: 'done' }] },
};
}
);
// customAgentWithArtifactsStore: server-managed, adds a numbered artifact
// on each invocation. Used for verifying artifact persistence across
// snapshot-based invocations.
const customAgentWithArtifactsStore = defineCustomAgent(
registry,
{
name: 'customAgentWithArtifactsStore',
store: new InMemorySessionStore(),
},
async (sess) => {
await sess.run(async () => {
const existing = sess.session.getArtifacts();
const count = existing.length + 1;
sess.session.addArtifacts([
{ name: `doc${count}`, parts: [{ text: `content${count}` }] },
]);
});
return {
artifacts: sess.session.getArtifacts(),
message: { role: 'model', content: [{ text: 'done' }] },
};
}
);
// customAgentWithCustomStateStore: server-managed, increments a counter
// on each turn. Used for verifying custom state persistence via snapshots.
const customAgentWithCustomStateStore = defineCustomAgent(
registry,
{
name: 'customAgentWithCustomStateStore',
store: new InMemorySessionStore(),
},
async (sess) => {
await sess.run(async () => {
const prev = (sess.session.getCustom() as any) || {};
const counter = (prev.counter || 0) + 1;
sess.session.updateCustom(() => ({ counter }));
});
return {
message: { role: 'model', content: [{ text: 'done' }] },
};
}
);
return {
promptAgent,
promptAgentWithStore,
promptAgentWithTools,
promptAgentWithInterrupt,
promptAgentWithRestartTool,
customAgentBlocking,
customAgentFailing,
customAgentWithArtifacts,
customAgentWithCustomState,
customAgentWithMultiCustomState,
customAgentWithArtifactsStore,
customAgentWithCustomStateStore,
};
}
// ---------------------------------------------------------------------------
// Invocation executors
// ---------------------------------------------------------------------------
async function executeSendInvocation(
agent: Agent,
pm: ProgrammableModel,
invocation: SendInvocation,
captures: Map<string, any>
): Promise<void> {
const resolvedInvocation = resolveTemplates(invocation, captures);
// Program the model
if (resolvedInvocation.modelResponses || resolvedInvocation.streamChunks) {
let reqCounter = 0;
pm.handleResponse = async (req, sc) => {
if (resolvedInvocation.streamChunks?.[reqCounter] && sc) {
for (const chunk of resolvedInvocation.streamChunks[reqCounter]) {
sc(chunk);
}
}
return resolvedInvocation.modelResponses?.[reqCounter++]!;
};
}
// Start the bidi session
const init = resolvedInvocation.init || {};
const session = agent.streamBidi(init);
// Send all inputs
const inputs = resolvedInvocation.inputs || [];
for (const input of inputs) {
session.send(input);
}
session.close();
// expectError: the turn is expected to throw (API misuse) rather than resolve
// with a graceful `finishReason: 'failed'` output. Drain the stream and await
// the output, asserting that one of them throws an error whose `status`
// matches exactly and `message` contains the expected substring.
if (resolvedInvocation.expectError) {
const expectErr = resolvedInvocation.expectError;
let thrown: any;
try {
for await (const _chunk of session.stream) {
// Drain; the stream surfaces the thrown error.
}
await session.output;
} catch (e: any) {
thrown = e;
}
assert.ok(
thrown,
`Expected the turn to throw an error, but it resolved successfully.`
);
if (expectErr.status !== undefined) {
assert.strictEqual(
thrown.status,
expectErr.status,
`Expected thrown error.status '${expectErr.status}', got '${thrown.status}' (message: ${thrown.message})`
);
}
if (expectErr.message !== undefined) {
assert.ok(
thrown.message?.includes(expectErr.message),
`Expected thrown error.message to contain '${expectErr.message}', got: ${thrown.message}`
);
}
return;
}
// Collect stream chunks
const chunks: AgentStreamChunk[] = [];
for await (const chunk of session.stream) {
chunks.push(chunk);
}
// Get the output
const output = await session.output;
// --- Assertions ---
// expectChunks: strict ordered comparison
if (resolvedInvocation.expectChunks) {
const strippedChunks = chunks.map((c) => deepStrip(stripUndefinedProps(c)));
const expectedChunks = resolvedInvocation.expectChunks.map((c: any) =>
deepStrip(c)
);
// For strict comparison, we compare each expected chunk against actual.
// turnEnd chunks may contain dynamic snapshotIds, so we do field-level
// matching: if the expected chunk has a field, it must match; extra fields
// in actual are allowed for turnEnd only.
assert.strictEqual(
strippedChunks.length,
expectedChunks.length,
`Expected ${expectedChunks.length} chunks, got ${strippedChunks.length}.\n` +
` Actual: ${JSON.stringify(strippedChunks)}\n` +
` Expected: ${JSON.stringify(expectedChunks)}`
);
for (let i = 0; i < expectedChunks.length; i++) {
const expected = expectedChunks[i];
const actual = strippedChunks[i];
if (expected.turnEnd !== undefined) {
// turnEnd: verify turnEnd key exists, but snapshotId is dynamic.
// finishReason, when specified in the spec, must match exactly.
assert.ok(
actual.turnEnd !== undefined,
`Chunk ${i}: expected turnEnd, got ${JSON.stringify(actual)}`
);
if (expected.turnEnd.finishReason !== undefined) {
assert.strictEqual(
actual.turnEnd?.finishReason,
expected.turnEnd.finishReason,
`Chunk ${i}: expected turnEnd.finishReason '${expected.turnEnd.finishReason}', got '${actual.turnEnd?.finishReason}'`
);
}
} else if (expected.modelChunk !== undefined) {
assertContains(
actual.modelChunk,
expected.modelChunk,
`chunk[${i}].modelChunk`
);
} else if (expected.artifact !== undefined) {
assertContains(
actual.artifact,
expected.artifact,
`chunk[${i}].artifact`
);
} else if (expected.customPatch !== undefined) {
assertContains(
actual.customPatch,
expected.customPatch,
`chunk[${i}].customPatch`
);
} else {
// Generic deep comparison for other chunk types
assertContains(actual, expected, `chunk[${i}]`);
}
}
}
// expectOutput
if (resolvedInvocation.expectOutput) {
const expect = resolvedInvocation.expectOutput;
// message: strict comparison
if (expect.message) {
assertContains(
deepStrip(stripUndefinedProps(output.message)),
deepStrip(expect.message),
'output.message'
);
}
// hasSnapshotId
if (expect.hasSnapshotId) {
assert.ok(
output.snapshotId && typeof output.snapshotId === 'string',
`Expected output to have a snapshotId, got: ${output.snapshotId}`
);
}
// hasSessionId: verify output.state contains a non-empty sessionId
if (expect.hasSessionId) {
assert.ok(
output.state,
'Expected output to have state for sessionId check'
);
assert.ok(
output.state.sessionId && typeof output.state.sessionId === 'string',
`Expected output.state to have a sessionId, got: ${output.state?.sessionId}`
);
}
// stateContains: partial matching
if (expect.stateContains) {
assert.ok(output.state, 'Expected output to have state');
assertContains(output.state, expect.stateContains, 'output.state');
}
// artifactsContain: partial matching
if (expect.artifactsContain) {
assert.ok(output.artifacts, 'Expected output to have artifacts');
for (const expectedArt of expect.artifactsContain) {
const found = output.artifacts!.find(
(a: any) => a.name === expectedArt.name
);
assert.ok(
found,
`Expected artifact "${expectedArt.name}" not found in output`
);
assertContains(found, expectedArt, `artifact(${expectedArt.name})`);
}
}
// finishReason: exact match. Covers the finish-reasons contract — e.g.
// 'stop' on a normal completion, 'interrupted' on a tool pause, and
// 'failed' on graceful error handling.
if (expect.finishReason !== undefined) {
assert.strictEqual(
output.finishReason,
expect.finishReason,
`Expected output.finishReason '${expect.finishReason}', got '${output.finishReason}'`
);
}
// errorContains: partial match on the structured error returned by the
// graceful-failure path ({ status, message, details? }). `status` is
// matched exactly and `message` as a substring.
if (expect.errorContains) {
assert.ok(
output.error,
`Expected output to have an error, got: ${JSON.stringify(output.error)}`
);
if (expect.errorContains.status !== undefined) {
assert.strictEqual(
output.error!.status,
expect.errorContains.status,
`Expected output.error.status '${expect.errorContains.status}', got '${output.error!.status}'`
);
}
if (expect.errorContains.message !== undefined) {
assert.ok(
output.error!.message?.includes(expect.errorContains.message),
`Expected output.error.message to contain '${expect.errorContains.message}', got: ${output.error!.message}`
);
}
}
}
// Capture values for subsequent invocations
if (invocation.captureSnapshotId) {
assert.ok(
output.snapshotId,
`captureSnapshotId '${invocation.captureSnapshotId}' requested but output has no snapshotId`
);
captures.set(invocation.captureSnapshotId, output.snapshotId);
}
if (invocation.captureState) {
assert.ok(
output.state,
`captureState '${invocation.captureState}' requested but output has no state`
);
captures.set(invocation.captureState, output.state);
}
if (invocation.captureSessionId) {
assert.ok(
output.state?.sessionId,
`captureSessionId '${invocation.captureSessionId}' requested but output has no state.sessionId`
);
captures.set(invocation.captureSessionId, output.state.sessionId);
}
}
async function executeGetSnapshotDataInvocation(
agent: Agent,
invocation: SpecStep,
captures: Map<string, any>
): Promise<void> {
const resolved = resolveTemplates(invocation, captures);
const { snapshotId, sessionId } = resolved;
assert.ok(
!!snapshotId !== !!sessionId,
'getSnapshotData invocation requires exactly one of snapshotId or sessionId'
);
// Lookup by either snapshotId (exact) or sessionId (latest leaf snapshot).
const lookup = snapshotId ? { snapshotId } : { sessionId };
// If the spec expects an error (e.g. branching session rejects sessionId
// lookup), assert it is thrown and stop.
if (resolved.expectError) {
try {
await agent.getSnapshotData(lookup);
assert.fail(
`Expected error containing "${resolved.expectError}" but getSnapshotData succeeded`
);
} catch (e: any) {
assert.ok(
e.message?.includes(resolved.expectError),
`Expected error containing "${resolved.expectError}", got: ${e.message}`
);
}
return;
}
const snapshot = await agent.getSnapshotData(lookup);
assert.ok(snapshot, `Snapshot not found for ${JSON.stringify(lookup)}`);
if (resolved.expectSnapshot) {
const expect = resolved.expectSnapshot;
if (expect.parentId !== undefined) {
assert.strictEqual(
snapshot.parentId,
expect.parentId,
`Expected parentId '${expect.parentId}', got '${snapshot.parentId}'`
);
}
if (expect.status !== undefined) {
assert.strictEqual(
snapshot.status,
expect.status,
`Expected status '${expect.status}', got '${snapshot.status}'`
);
}
if (expect.hasSessionId) {
assert.ok(
snapshot.state?.sessionId &&
typeof snapshot.state.sessionId === 'string',
`Expected snapshot.state to have a sessionId, got: ${snapshot.state?.sessionId}`
);
}
if (expect.stateContains) {
assertContains(snapshot.state, expect.stateContains, 'snapshot.state');
}
if (expect.errorContains) {
assert.ok(snapshot.error, 'Expected snapshot to have error');
assertContains(snapshot.error, expect.errorContains, 'snapshot.error');
}
}
}
async function executeAbortInvocation(
agent: Agent,
invocation: SpecStep,
captures: Map<string, any>
): Promise<void> {
const resolved = resolveTemplates(invocation, captures);
const snapshotId = resolved.snapshotId;
assert.ok(snapshotId, 'abort invocation requires snapshotId');
const previousStatus = await agent.abort(snapshotId);
// The `expectPreviousStatus` key being present (even as null/~) means we
// should assert. YAML `~` maps to JS `null`; agent.abort() returns
// `undefined` for non-existent snapshots — treat both as "absent".
if ('expectPreviousStatus' in resolved) {
const expected = resolved.expectPreviousStatus ?? undefined;
assert.strictEqual(
previousStatus,
expected,
`Expected previous status '${expected}', got '${previousStatus}'`
);
}
}
async function executeWaitUntilCompletedInvocation(
agent: Agent,
invocation: SpecStep,
captures: Map<string, any>
): Promise<void> {
const resolved = resolveTemplates(invocation, captures);
const snapshotId = resolved.snapshotId;
const timeoutMs = resolved.timeoutMs || 5000;
assert.ok(snapshotId, 'waitUntilCompleted invocation requires snapshotId');
const terminalStatuses = new Set(['completed', 'failed', 'aborted']);
const startTime = Date.now();
let snapshot: any;
while (Date.now() - startTime < timeoutMs) {
snapshot = await agent.getSnapshotData({ snapshotId: snapshotId });
if (snapshot && terminalStatuses.has(snapshot.status)) {
break;
}
await new Promise((r) => setTimeout(r, 100));
}
assert.ok(snapshot, `Snapshot ${snapshotId} not found after waiting`);
assert.ok(
terminalStatuses.has(snapshot.status),
`Snapshot ${snapshotId} did not reach terminal status within ${timeoutMs}ms. Status: ${snapshot.status}`
);
if (resolved.expectSnapshot) {
const expect = resolved.expectSnapshot;
if (expect.status !== undefined) {
assert.strictEqual(snapshot.status, expect.status);
}
if (expect.hasSessionId) {
assert.ok(
snapshot.state?.sessionId &&
typeof snapshot.state.sessionId === 'string',
`Expected snapshot.state to have a sessionId, got: ${snapshot.state?.sessionId}`
);
}
if (expect.stateContains) {
assertContains(snapshot.state, expect.stateContains, 'snapshot.state');
}
if (expect.errorContains) {
assert.ok(snapshot.error, 'Expected snapshot to have error');
assertContains(snapshot.error, expect.errorContains, 'snapshot.error');
}
}
}
// ---------------------------------------------------------------------------
// Test runner
// ---------------------------------------------------------------------------
const specPath = '../../tests/specs/agent.yaml';
const spec = SpecSuiteSchema.parse(parse(readFileSync(specPath, 'utf-8')));
describe('Agent conformance spec', () => {
let registry: Registry;
let pm: ProgrammableModel;
let agents: HarnessAgents;
beforeEach(() => {
registry = new Registry();
registry.apiStability = 'beta';
pm = defineProgrammableModel(registry);
agents = setupHarness(registry, pm);
});
for (const test of spec.tests) {
it(test.name, async () => {
const agent = agents[test.agent];
assert.ok(agent, `Unknown agent '${test.agent}' in test '${test.name}'`);
const captures = new Map<string, any>();
for (let i = 0; i < test.steps.length; i++) {
const step = test.steps[i];
const label = `step[${i}] (${step.type})`;
try {
switch (step.type) {
case 'send':
await executeSendInvocation(agent, pm, step, captures);
break;
case 'getSnapshotData':
await executeGetSnapshotDataInvocation(agent, step, captures);
break;
case 'abort':
await executeAbortInvocation(agent, step, captures);
break;
case 'waitUntilCompleted':
await executeWaitUntilCompletedInvocation(agent, step, captures);
break;
default:
assert.fail(`Unknown step type: ${(step as any).type}`);
}
} catch (e: any) {
// Wrap error with step context for better diagnostics
const wrapped = new Error(
`${label} in test '${test.name}' failed: ${e.message}`
);
wrapped.stack = e.stack;
throw wrapped;
}
}
});
}
});