UNPKG

@genkit-ai/ai

Version:

Genkit AI framework generative AI APIs.

1,029 lines (922 loc) 33.1 kB
/** * 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; } } }); } });