@genkit-ai/ai
Version:
Genkit AI framework generative AI APIs.
1,550 lines (1,339 loc) • 135 kB
text/typescript
/**
* Copyright 2026 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.
*/
import { initNodeFeatures } from '@genkit-ai/core/node';
import { Registry } from '@genkit-ai/core/registry';
import { enableTelemetry } from '@genkit-ai/core/tracing';
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
import * as assert from 'assert';
import { describe, it } from 'node:test';
import { z } from '@genkit-ai/core';
import { TestSpanExporter } from '../../core/tests/utils.js';
import { AgentError } from '../src/agent-core.js';
import {
AgentStreamChunk,
SessionRunner,
defineAgent,
defineCustomAgent,
definePromptAgent,
} from '../src/agent.js';
import { definePrompt } from '../src/prompt.js';
import { InMemorySessionStore } from '../src/session-stores.js';
import {
Session,
reserveSnapshotId,
type SessionSnapshot,
} from '../src/session.js';
import { ToolInterruptError, defineTool, interrupt } from '../src/tool.js';
import {
defineEchoModel,
defineProgrammableModel,
type ProgrammableModel,
} from './helpers.js';
initNodeFeatures();
const spanExporter = new TestSpanExporter();
enableTelemetry({
spanProcessors: [new SimpleSpanProcessor(spanExporter)],
});
/**
* Returns a Promise that resolves once the given snapshotId reaches targetStatus
* in the store. Rejects after timeoutMs if the status is never reached.
*/
function waitForSnapshotStatus<S>(
store: InMemorySessionStore<S>,
snapshotId: string,
targetStatus: NonNullable<SessionSnapshot<S>['status']>,
timeoutMs = 5000
): Promise<SessionSnapshot<S>> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() =>
reject(
new Error(
`Timed out waiting for snapshot ${snapshotId} to reach status "${targetStatus}"`
)
),
timeoutMs
);
const unsubscribeFn = store.onSnapshotStateChange(snapshotId, (snap) => {
if (snap.status === targetStatus) {
clearTimeout(timer);
if (typeof unsubscribeFn === 'function') unsubscribeFn();
resolve(snap);
}
});
// Check in case already at the target status.
store.getSnapshot({ snapshotId: snapshotId }).then((snap) => {
if (snap?.status === targetStatus) {
clearTimeout(timer);
if (typeof unsubscribeFn === 'function') unsubscribeFn();
resolve(snap);
}
});
});
}
describe('Agent', () => {
describe('Session', () => {
it('should maintain custom state', () => {
const session = new Session<{ foo: string }>({ custom: { foo: 'bar' } });
assert.strictEqual(session.getCustom()?.foo, 'bar');
session.updateCustom((c) => ({ ...c!, foo: 'baz' }));
assert.strictEqual(session.getCustom()?.foo, 'baz');
});
it('should add and set messages', () => {
const session = new Session({});
session.addMessages([{ role: 'user', content: [{ text: 'hi' }] }]);
assert.strictEqual(session.getMessages().length, 1);
assert.strictEqual(session.getMessages()[0].role, 'user');
session.setMessages([{ role: 'model', content: [{ text: 'hello' }] }]);
assert.strictEqual(session.getMessages().length, 1);
assert.strictEqual(session.getMessages()[0].role, 'model');
});
it('should add and deduplicate artifacts', () => {
const session = new Session({});
session.addArtifacts([{ name: 'art1', parts: [{ text: 'content1' }] }]);
assert.strictEqual(session.getArtifacts().length, 1);
// Add with same name should replace
session.addArtifacts([{ name: 'art1', parts: [{ text: 'content2' }] }]);
assert.strictEqual(session.getArtifacts().length, 1);
assert.deepStrictEqual(session.getArtifacts()[0].parts, [
{ text: 'content2' },
]);
// Add with different name should append
session.addArtifacts([{ name: 'art2', parts: [{ text: 'content3' }] }]);
assert.strictEqual(session.getArtifacts().length, 2);
});
it('should process all artifacts in a batch without dropping any', () => {
const session = new Session({});
session.addArtifacts([{ name: 'art1', parts: [{ text: 'v1' }] }]);
// Replace art1 and add art2 and art3 in the same batch.
session.addArtifacts([
{ name: 'art1', parts: [{ text: 'v2' }] },
{ name: 'art2', parts: [{ text: 'new' }] },
{ name: 'art3', parts: [{ text: 'another' }] },
]);
const arts = session.getArtifacts();
assert.strictEqual(arts.length, 3);
assert.strictEqual(
arts.find((a) => a.name === 'art1')?.parts[0].text,
'v2'
);
assert.strictEqual(
arts.find((a) => a.name === 'art2')?.parts[0].text,
'new'
);
assert.strictEqual(
arts.find((a) => a.name === 'art3')?.parts[0].text,
'another'
);
});
it('should emit artifactAdded for new and artifactUpdated for replaced', () => {
const session = new Session({});
const added: string[] = [];
const updated: string[] = [];
session.on('artifactAdded', (a: { name?: string }) =>
added.push(a.name ?? '')
);
session.on('artifactUpdated', (a: { name?: string }) =>
updated.push(a.name ?? '')
);
session.addArtifacts([{ name: 'art1', parts: [] }]);
session.addArtifacts([
{ name: 'art1', parts: [] }, // replace
{ name: 'art2', parts: [] }, // new
]);
assert.deepStrictEqual(added, ['art1', 'art2']);
assert.deepStrictEqual(updated, ['art1']);
});
it('should increment version on mutation', () => {
const session = new Session({});
const v0 = session.getVersion();
session.addMessages([{ role: 'user', content: [{ text: 'hi' }] }]);
const v1 = session.getVersion();
assert.ok(v1 > v0);
session.updateCustom((c) => c);
const v2 = session.getVersion();
assert.ok(v2 > v1);
session.addArtifacts([{ name: 'a', parts: [] }]);
const v3 = session.getVersion();
assert.ok(v3 > v2);
});
});
describe('SessionRunner', () => {
it('should loop over inputs and call handler', async () => {
const session = new Session({});
const inputs = [
{ message: { role: 'user' as const, content: [{ text: 'hi' }] } },
{ message: { role: 'user' as const, content: [{ text: 'bye' }] } },
];
async function* inputGen() {
for (const input of inputs) {
yield input;
}
}
const runner = new SessionRunner(session, inputGen());
let turns = 0;
const seenInputs: any[] = [];
await runner.run(async (input) => {
turns++;
seenInputs.push(input);
});
assert.strictEqual(turns, 2);
assert.deepStrictEqual(seenInputs, inputs);
assert.strictEqual(session.getMessages().length, 2);
});
it('should trigger snapshots if store is present', async () => {
const store = new InMemorySessionStore();
const session = new Session({});
const inputs = [
{ message: { role: 'user' as const, content: [{ text: 'hi' }] } },
];
async function* inputGen() {
for (const input of inputs) {
yield input;
}
}
let turnEnded = false;
let turnSnapshotId: string | undefined;
const runner = new SessionRunner(session, inputGen(), {
store,
onEndTurn: (snapshotId) => {
turnEnded = true;
turnSnapshotId = snapshotId;
},
});
await runner.run(async () => {});
assert.ok(turnEnded);
assert.ok(turnSnapshotId);
const saved = await store.getSnapshot({ snapshotId: turnSnapshotId! });
assert.ok(saved);
assert.strictEqual(saved?.snapshotId, turnSnapshotId);
});
it('reserves the turn snapshotId at turn start and persists under it', async () => {
const store = new InMemorySessionStore();
const session = new Session({});
async function* inputGen() {
yield {
message: { role: 'user' as const, content: [{ text: 'hi' }] },
};
}
let ctxSnapshotId: string | undefined;
let ctxParentSnapshotId: string | undefined;
let ctxTurnIndex: number | undefined;
let endTurnSnapshotId: string | undefined;
const runner = new SessionRunner(session, inputGen(), {
store,
onEndTurn: (snapshotId) => {
endTurnSnapshotId = snapshotId;
},
});
await runner.run(async (_input, ctx) => {
ctxSnapshotId = ctx.snapshotId;
ctxParentSnapshotId = ctx.parentSnapshotId;
ctxTurnIndex = ctx.turnIndex;
});
// The id is reserved at turn start and made available to the handler.
assert.ok(ctxSnapshotId, 'handler should receive a reserved snapshotId');
// First turn of a fresh session has no parent.
assert.strictEqual(ctxParentSnapshotId, undefined);
assert.strictEqual(ctxTurnIndex, 0);
// The snapshot persisted at turn end reuses the reserved id.
assert.strictEqual(endTurnSnapshotId, ctxSnapshotId);
const saved = await store.getSnapshot({ snapshotId: ctxSnapshotId! });
assert.ok(saved);
assert.strictEqual(saved?.snapshotId, ctxSnapshotId);
});
it('passes the prior snapshotId as parentSnapshotId on subsequent turns', async () => {
const store = new InMemorySessionStore();
const session = new Session({});
async function* inputGen() {
yield {
message: { role: 'user' as const, content: [{ text: 'one' }] },
};
yield {
message: { role: 'user' as const, content: [{ text: 'two' }] },
};
}
const snapshotIds: string[] = [];
const parentIds: Array<string | undefined> = [];
const runner = new SessionRunner(session, inputGen(), { store });
await runner.run(async (_input, ctx) => {
snapshotIds.push(ctx.snapshotId);
parentIds.push(ctx.parentSnapshotId);
});
assert.strictEqual(snapshotIds.length, 2);
// First turn: no parent. Second turn: parent is the first turn's snapshot.
assert.strictEqual(parentIds[0], undefined);
assert.strictEqual(parentIds[1], snapshotIds[0]);
});
it('does not reserve a snapshotId when no store is configured', async () => {
const session = new Session({});
async function* inputGen() {
yield {
message: { role: 'user' as const, content: [{ text: 'hi' }] },
};
}
let ctxSnapshotId: string | undefined = 'sentinel';
const runner = new SessionRunner(session, inputGen());
await runner.run(async (_input, ctx) => {
ctxSnapshotId = ctx.snapshotId;
});
// Without a store there is nothing to reserve, so snapshotId is undefined.
assert.strictEqual(ctxSnapshotId, undefined);
});
});
describe('reserveSnapshotId', () => {
const UUID =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
it('mints a plain UUID snapshotId', () => {
const id = reserveSnapshotId();
assert.match(id, UUID, `id should be a UUID: ${id}`);
});
it('produces unique ids on successive calls', () => {
const a = reserveSnapshotId();
const b = reserveSnapshotId();
assert.notStrictEqual(a, b);
});
});
describe('defineCustomAgent', () => {
it('should set client stateManagement and abortable=false when no store is provided', () => {
const registry = new Registry();
const agent = defineCustomAgent(
registry,
{ name: 'noStoreMetadataTest' },
async () => ({ artifacts: [] })
);
assert.strictEqual(
agent.__action.metadata?.agent?.stateManagement,
'client'
);
assert.strictEqual(agent.__action.metadata?.agent?.abortable, false);
});
it('should set server stateManagement and abortable=true when store with onSnapshotStateChange is provided', () => {
const registry = new Registry();
const store = new InMemorySessionStore();
const agent = defineCustomAgent(
registry,
{ name: 'fullStoreMetadataTest', store },
async () => ({ artifacts: [] })
);
assert.strictEqual(
agent.__action.metadata?.agent?.stateManagement,
'server'
);
assert.strictEqual(agent.__action.metadata?.agent?.abortable, true);
});
it('should reject init.state for server-managed agents (store is set)', async () => {
const registry = new Registry();
const store = new InMemorySessionStore<{ foo: string }>();
const flow = defineCustomAgent<{ foo: string }>(
registry,
{ name: 'rejectInitStateTest', store },
async (sess) => {
await sess.run(async () => {});
return {
artifacts: [],
message: { role: 'model', content: [{ text: 'done' }] },
};
}
);
// Pass init.state - this is API misuse for a server-managed agent and
// must throw a FAILED_PRECONDITION error (rather than resolving with a
// graceful finishReason 'failed' output).
const session = flow.streamBidi({
state: {
custom: { foo: 'should-be-rejected' },
messages: [{ role: 'user', content: [{ text: 'stale history' }] }],
artifacts: [],
},
});
session.send({
message: { role: 'user', content: [{ text: 'hello' }] },
});
session.close();
let thrown: any;
try {
for await (const _ of session.stream) {
}
await session.output;
} catch (e: any) {
thrown = e;
}
assert.ok(thrown, 'Expected the turn to throw an error');
assert.strictEqual(thrown.status, 'FAILED_PRECONDITION');
assert.ok(
thrown.message.includes("Cannot send 'state' to agent"),
`Expected FAILED_PRECONDITION message, got: ${thrown.message}`
);
});
it('should use init.state for client-managed agents (no store)', async () => {
const registry = new Registry();
const flow = defineCustomAgent<{ foo: string }>(
registry,
{ name: 'useInitStateTest' },
async (sess) => {
await sess.run(async () => {});
return {
artifacts: [],
message: { role: 'model', content: [{ text: 'done' }] },
};
}
);
// Pass init.state - it should be used because no store is set
const session = flow.streamBidi({
state: {
custom: { foo: 'seeded' },
messages: [{ role: 'user', content: [{ text: 'prior msg' }] }],
artifacts: [],
},
});
session.send({
message: { role: 'user', content: [{ text: 'hello' }] },
});
session.close();
for await (const _ of session.stream) {
}
const output = await session.output;
// State should include the seeded state plus the new message
assert.ok(output.state);
assert.strictEqual((output.state!.custom as any).foo, 'seeded');
// Messages: 1 from init.state + 1 from input
assert.strictEqual(output.state!.messages!.length, 2);
assert.strictEqual(
output.state!.messages![0].content[0].text,
'prior msg'
);
assert.strictEqual(output.state!.messages![1].content[0].text, 'hello');
});
it('should set server stateManagement and abortable=false when store lacks onSnapshotStateChange', () => {
const registry = new Registry();
const store: any = {
getSnapshot: async () => undefined,
saveSnapshot: async () => {},
// no onSnapshotStateChange
};
const agent = defineCustomAgent(
registry,
{ name: 'noAbortStoreMetadataTest', store },
async () => ({ artifacts: [] })
);
assert.strictEqual(
agent.__action.metadata?.agent?.stateManagement,
'server'
);
assert.strictEqual(agent.__action.metadata?.agent?.abortable, false);
});
it('should register and execute agent', async () => {
const registry = new Registry();
const flow = defineCustomAgent(
registry,
{ name: 'testFlow' },
async (sess, { sendChunk }) => {
let receivedInput = false;
await sess.run(async (input) => {
receivedInput = true;
assert.strictEqual(input.message?.role, 'user');
});
assert.ok(receivedInput);
return { message: { role: 'model', content: [{ text: 'done' }] } };
}
);
const session = flow.streamBidi({});
session.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
});
session.close();
const chunks: AgentStreamChunk[] = [];
for await (const chunk of session.stream) {
chunks.push(chunk);
}
const output = await session.output;
assert.strictEqual(output.message?.role, 'model');
assert.strictEqual(output.message?.content[0].text, 'done');
});
it('should automatically stream artifacts added via Session.addArtifacts()', async () => {
const registry = new Registry();
const flow = defineCustomAgent(
registry,
{ name: 'testEventFlow' },
async (sess, { sendChunk }) => {
await sess.run(async (input) => {
sess.session.addArtifacts([
{ name: 'testArt', parts: [{ text: 'testPart' }] },
]);
});
return { message: { role: 'model', content: [{ text: 'done' }] } };
}
);
const session = flow.streamBidi({});
session.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
});
session.close();
const chunks: AgentStreamChunk[] = [];
for await (const chunk of session.stream) {
chunks.push(chunk);
}
const artChunks = chunks.filter((c) => !!c.artifact);
assert.strictEqual(artChunks.length, 1);
assert.strictEqual(artChunks[0].artifact?.name, 'testArt');
});
it('should stream artifactUpdated chunks when an artifact is replaced', async () => {
const registry = new Registry();
const flow = defineCustomAgent(
registry,
{ name: 'testArtifactUpdateFlow' },
async (sess) => {
await sess.run(async () => {
sess.session.addArtifacts([{ name: 'a', parts: [{ text: 'v1' }] }]);
sess.session.addArtifacts([{ name: 'a', parts: [{ text: 'v2' }] }]);
});
return {};
}
);
const session = flow.streamBidi({});
session.send({ message: { role: 'user', content: [{ text: 'go' }] } });
session.close();
const chunks: AgentStreamChunk[] = [];
for await (const chunk of session.stream) {
chunks.push(chunk);
}
const artChunks = chunks.filter((c) => !!c.artifact);
assert.strictEqual(artChunks.length, 2);
assert.strictEqual(artChunks[0].artifact?.parts[0].text, 'v1');
assert.strictEqual(artChunks[1].artifact?.parts[0].text, 'v2');
});
it('records the snapshotId and state on the turn span (server-managed)', async () => {
spanExporter.exportedSpans = [];
const store = new InMemorySessionStore<{ count: number }>();
const flow = defineCustomAgent<{ count: number }>(
new Registry(),
{ name: 'turnSpanServerTest', store },
async (sess) => {
await sess.run(async () => {
sess.updateCustom((c) => ({ count: (c?.count ?? 0) + 1 }));
return { finishReason: 'stop' as const };
});
return {
message: { role: 'model', content: [{ text: 'done' }] },
finishReason: 'stop' as const,
};
}
);
const session = flow.streamBidi({});
session.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
});
session.close();
for await (const _ of session.stream) {
}
const output = await session.output;
assert.ok(output.snapshotId);
const turnSpan = spanExporter.exportedSpans.find(
(s) => s.displayName === 'runTurn-1'
);
assert.ok(turnSpan, 'expected a runTurn-1 span to be exported');
// The turn span carries the snapshotId this turn persisted under.
assert.strictEqual(
turnSpan.attributes['genkit:metadata:agent:snapshotId'],
output.snapshotId
);
// The turn span's output is the session state this turn produced.
const out = JSON.parse(turnSpan.attributes['genkit:output']);
assert.deepStrictEqual(out.state.custom, { count: 1 });
});
it('records state on the turn span for a client-managed agent', async () => {
spanExporter.exportedSpans = [];
const registry = new Registry();
const flow = defineCustomAgent<{ count: number }>(
registry,
{ name: 'turnSpanClientTest' },
async (sess) => {
await sess.run(async () => {
sess.updateCustom((c) => ({ count: (c?.count ?? 0) + 1 }));
return { finishReason: 'stop' as const };
});
return {
message: { role: 'model', content: [{ text: 'done' }] },
finishReason: 'stop' as const,
};
}
);
const session = flow.streamBidi({});
session.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
});
session.close();
for await (const _ of session.stream) {
}
await session.output;
const turnSpan = spanExporter.exportedSpans.find(
(s) => s.displayName === 'runTurn-1'
);
assert.ok(turnSpan, 'expected a runTurn-1 span to be exported');
// The turn span's output carries the session state this turn produced,
// for client-managed agents too.
const out = JSON.parse(turnSpan.attributes['genkit:output']);
assert.deepStrictEqual(out.state.custom, { count: 1 });
});
});
describe('sessionId', () => {
it('should generate sessionId for a fresh client-managed agent', async () => {
const registry = new Registry();
const flow = defineCustomAgent(
registry,
{ name: 'sessionIdFreshClient' },
async (sess) => {
await sess.run(async () => {});
return { message: { role: 'model', content: [{ text: 'done' }] } };
}
);
const session = flow.streamBidi({});
session.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
});
session.close();
for await (const _ of session.stream) {
}
const output = await session.output;
// Client-managed agents return state, which should contain a sessionId
assert.ok(output.state, 'output.state should be present');
assert.ok(
output.state!.sessionId,
'sessionId should be generated for a fresh session'
);
// Should be a valid UUID
assert.match(
output.state!.sessionId!,
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
);
});
it('should preserve sessionId across turns for client-managed agents', async () => {
const registry = new Registry();
const flow = defineCustomAgent(
registry,
{ name: 'sessionIdPreserveClient' },
async (sess) => {
await sess.run(async () => {});
return { message: { role: 'model', content: [{ text: 'done' }] } };
}
);
// Turn 1: fresh session
const session1 = flow.streamBidi({});
session1.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
});
session1.close();
for await (const _ of session1.stream) {
}
const output1 = await session1.output;
const firstSessionId = output1.state!.sessionId!;
assert.ok(firstSessionId, 'First turn should have sessionId');
// Turn 2: pass state back (client-managed)
const session2 = flow.streamBidi({ state: output1.state });
session2.send({
message: { role: 'user' as const, content: [{ text: 'bye' }] },
});
session2.close();
for await (const _ of session2.stream) {
}
const output2 = await session2.output;
assert.strictEqual(
output2.state!.sessionId,
firstSessionId,
'sessionId should be preserved across turns'
);
});
it('should generate sessionId for a fresh server-managed agent and persist in snapshot', async () => {
const registry = new Registry();
const store = new InMemorySessionStore();
const flow = defineCustomAgent(
registry,
{ name: 'sessionIdServerManaged', store },
async (sess) => {
await sess.run(async () => {});
return { message: { role: 'model', content: [{ text: 'done' }] } };
}
);
const session = flow.streamBidi({});
session.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
});
session.close();
for await (const _ of session.stream) {
}
const output = await session.output;
assert.ok(output.snapshotId, 'should have snapshotId');
// Read snapshot and verify sessionId is persisted in the state
const snapshot = await store.getSnapshot({
snapshotId: output.snapshotId!,
});
assert.ok(snapshot, 'snapshot should exist');
assert.ok(
snapshot!.state.sessionId,
'sessionId should be persisted in snapshot state'
);
assert.match(
snapshot!.state.sessionId!,
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
);
});
it('should preserve sessionId from snapshot for server-managed agents', async () => {
const registry = new Registry();
const store = new InMemorySessionStore();
const flow = defineCustomAgent(
registry,
{ name: 'sessionIdServerPreserve', store },
async (sess) => {
await sess.run(async () => {});
return { message: { role: 'model', content: [{ text: 'done' }] } };
}
);
// Turn 1
const session1 = flow.streamBidi({});
session1.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
});
session1.close();
for await (const _ of session1.stream) {
}
const output1 = await session1.output;
const firstSnapshotId = output1.snapshotId!;
const snapshot1 = await store.getSnapshot({
snapshotId: firstSnapshotId,
});
const firstSessionId = snapshot1!.state.sessionId!;
assert.ok(firstSessionId, 'First turn should have sessionId');
// Turn 2: resume from snapshot
const session2 = flow.streamBidi({ snapshotId: firstSnapshotId });
session2.send({
message: { role: 'user' as const, content: [{ text: 'bye' }] },
});
session2.close();
for await (const _ of session2.stream) {
}
const output2 = await session2.output;
const snapshot2 = await store.getSnapshot({
snapshotId: output2.snapshotId!,
});
assert.strictEqual(
snapshot2!.state.sessionId,
firstSessionId,
'sessionId should be preserved across turns via snapshot'
);
});
it('resumes a snapshot when both snapshotId and a matching sessionId are provided', async () => {
const registry = new Registry();
const store = new InMemorySessionStore();
const flow = defineCustomAgent(
registry,
{ name: 'snapshotAndMatchingSession', store },
async (sess) => {
await sess.run(async () => {});
return { message: { role: 'model', content: [{ text: 'done' }] } };
}
);
// Turn 1
const session1 = flow.streamBidi({});
session1.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
});
session1.close();
for await (const _ of session1.stream) {
}
const output1 = await session1.output;
const firstSnapshotId = output1.snapshotId!;
const sessionId = output1.sessionId!;
assert.ok(sessionId, 'First turn should have sessionId');
// Turn 2: resume passing BOTH the snapshotId and its owning sessionId.
const session2 = flow.streamBidi({
snapshotId: firstSnapshotId,
sessionId,
});
session2.send({
message: { role: 'user' as const, content: [{ text: 'bye' }] },
});
session2.close();
for await (const _ of session2.stream) {
}
const output2 = await session2.output;
assert.notStrictEqual(
output2.finishReason,
'failed',
`Expected a successful resume, got error: ${output2.error?.message}`
);
assert.strictEqual(
output2.sessionId,
sessionId,
'sessionId should be preserved when resuming by snapshotId + sessionId'
);
});
it('rejects when snapshotId belongs to a different session than the provided sessionId', async () => {
const registry = new Registry();
const store = new InMemorySessionStore();
const flow = defineCustomAgent(
registry,
{ name: 'snapshotSessionMismatch', store },
async (sess) => {
await sess.run(async () => {});
return { message: { role: 'model', content: [{ text: 'done' }] } };
}
);
// Create a snapshot that belongs to session A.
const session1 = flow.streamBidi({});
session1.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
});
session1.close();
for await (const _ of session1.stream) {
}
const output1 = await session1.output;
const snapshotId = output1.snapshotId!;
// Resume that snapshot but claim it belongs to a different session. This
// is API misuse and must throw (rather than resolving gracefully).
const session2 = flow.streamBidi({
snapshotId,
sessionId: 'a-different-session-id',
});
session2.send({
message: { role: 'user' as const, content: [{ text: 'bye' }] },
});
session2.close();
let thrown: any;
try {
for await (const _ of session2.stream) {
}
await session2.output;
} catch (e: any) {
thrown = e;
}
assert.ok(thrown, 'Expected the turn to throw an error');
assert.strictEqual(thrown.status, 'INVALID_ARGUMENT');
assert.ok(
thrown.message.includes('does not belong to session'),
`Expected an ownership-mismatch error, got: ${thrown.message}`
);
});
});
describe('definePromptAgent', () => {
it('should register and execute agent from prompt', async () => {
const registry = new Registry();
defineEchoModel(registry);
definePrompt(registry, {
name: 'agent',
model: 'echoModel',
config: { temperature: 1 },
system: 'hello from template',
});
const flow = definePromptAgent(registry, {
promptName: 'agent',
});
const session = flow.streamBidi({});
session.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
});
session.close();
const chunks: AgentStreamChunk[] = [];
for await (const chunk of session.stream) {
chunks.push(chunk);
}
const output = await session.output;
assert.strictEqual(output.message?.role, 'model');
});
it('should pass promptInput to the prompt template', async () => {
const registry = new Registry();
defineEchoModel(registry);
definePrompt(registry, {
name: 'personaAgentPrompt',
model: 'echoModel',
input: { schema: z.object({ persona: z.string() }) },
system: 'You are a {{persona}}.',
});
const flow = definePromptAgent<unknown, z.ZodTypeAny>(registry, {
promptName: 'personaAgentPrompt',
promptInput: { persona: 'pirate' },
});
const session = flow.streamBidi({});
session.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
});
session.close();
for await (const _ of session.stream) {
// drain
}
const output = await session.output;
// The echo model echoes the rendered system message, which interpolated
// the supplied promptInput.
assert.ok(
output.message?.content[0].text?.includes('You are a pirate.'),
`expected rendered prompt to include the promptInput, got: ${output.message?.content[0].text}`
);
});
it('should let agents in separate registries reuse one prompt definition with different promptInput', async () => {
// The same prompt definition (config) reused to build two differently
// customized agents. Each lives in its own registry because an agent is
// keyed by its promptName.
const promptConfig = {
name: 'sharedPersonaPrompt',
model: 'echoModel',
input: { schema: z.object({ persona: z.string() }) },
system: 'You are a {{persona}}.',
};
const buildAgent = (persona: string) => {
const registry = new Registry();
defineEchoModel(registry);
definePrompt(registry, promptConfig);
return definePromptAgent<unknown, z.ZodTypeAny>(registry, {
promptName: 'sharedPersonaPrompt',
promptInput: { persona },
});
};
const runAgent = async (flow: ReturnType<typeof buildAgent>) => {
const session = flow.streamBidi({});
session.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
});
session.close();
for await (const _ of session.stream) {
// drain
}
return session.output;
};
const pirateOut = await runAgent(buildAgent('pirate'));
const ninjaOut = await runAgent(buildAgent('ninja'));
assert.ok(pirateOut.message?.content[0].text?.includes('pirate'));
assert.ok(ninjaOut.message?.content[0].text?.includes('ninja'));
});
it('should detach asynchronously and continue execution in the background', async () => {
const store = new InMemorySessionStore<{ foo: string }>();
let resolvePromise: () => void = () => {};
const releasePromise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
const flow = defineCustomAgent<{ foo: string }>(
new Registry(),
{
name: 'detachTest',
store,
},
async (sess, { sendChunk }) => {
await sess.run(async () => {
await releasePromise;
});
return {
artifacts: [],
message: { role: 'model', content: [{ text: 'hi' }] },
};
}
);
const session = flow.streamBidi({});
session.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
detach: true,
});
const output = await session.output;
const snapshotId = output.snapshotId;
assert.ok(snapshotId);
const snapPending = await store.getSnapshot({ snapshotId: snapshotId! });
assert.strictEqual(snapPending?.status, 'pending');
resolvePromise();
session.close();
const snapDone = await waitForSnapshotStatus(
store,
snapshotId!,
'completed'
);
assert.strictEqual(snapDone.status, 'completed');
});
it('should abort a detached agent', async () => {
const store = new InMemorySessionStore<{ foo: string }>();
let aborted = false;
const flow = defineCustomAgent<{ foo: string }>(
new Registry(),
{
name: 'abortTest',
store,
},
async (sess, { abortSignal }) => {
if (abortSignal) {
abortSignal.onabort = () => {
aborted = true;
};
}
await sess.run(async () => {
await new Promise((resolve) => setTimeout(resolve, 5000));
});
return {
artifacts: [],
message: { role: 'model', content: [{ text: 'hi' }] },
};
}
);
const session = flow.streamBidi({});
session.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
detach: true,
});
const output = await session.output;
const snapshotId = output.snapshotId;
assert.ok(snapshotId);
const previousStatus = await flow.abort(snapshotId!);
assert.strictEqual(previousStatus, 'pending');
const snapAborted = await store.getSnapshot({ snapshotId: snapshotId! });
assert.strictEqual(snapAborted?.status, 'aborted');
// AbortController.abort() fires onabort synchronously, so no delay needed.
assert.strictEqual(aborted, true);
});
it('should stamp a heartbeat on the pending detached snapshot', async () => {
const store = new InMemorySessionStore<{ foo: string }>();
let resolvePromise: () => void = () => {};
const releasePromise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
const flow = defineCustomAgent<{ foo: string }>(
new Registry(),
{
name: 'heartbeatStampTest',
store,
},
async (sess) => {
await sess.run(async () => {
await releasePromise;
});
return {
artifacts: [],
message: { role: 'model', content: [{ text: 'hi' }] },
};
}
);
const session = flow.streamBidi({});
session.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
detach: true,
});
const output = await session.output;
const snapshotId = output.snapshotId!;
assert.ok(snapshotId);
const snapPending = await store.getSnapshot({ snapshotId });
assert.strictEqual(snapPending?.status, 'pending');
assert.ok(
snapPending?.heartbeatAt,
'pending detached snapshot should carry a heartbeatAt'
);
resolvePromise();
session.close();
await waitForSnapshotStatus(store, snapshotId, 'completed');
});
it('reports a pending snapshot with a stale heartbeat as expired', async () => {
const store = new InMemorySessionStore<{ foo: string }>();
const flow = defineCustomAgent<{ foo: string }>(
new Registry(),
{
name: 'heartbeatExpiredTest',
store,
},
async (sess) => {
await sess.run(async () => {});
return { artifacts: [] };
}
);
// Simulate an orphaned detached snapshot: pending, with a heartbeat that
// is older than the expiry timeout (default 60s).
const stale = new Date(Date.now() - 120_000).toISOString();
const snapshotId = await store.saveSnapshot(undefined, () => ({
createdAt: stale,
updatedAt: stale,
heartbeatAt: stale,
status: 'pending',
state: { sessionId: 'sess-expired', custom: { foo: 'bar' } },
}));
assert.ok(snapshotId);
// The raw store still has it as pending (compute-on-read does not write
// back), but getSnapshotData surfaces it as expired.
const raw = await store.getSnapshot({ snapshotId: snapshotId! });
assert.strictEqual(raw?.status, 'pending');
const viaData = await flow.getSnapshotData({ snapshotId: snapshotId! });
assert.strictEqual(viaData?.status, 'expired');
});
it('keeps a pending snapshot with a fresh heartbeat as pending', async () => {
const store = new InMemorySessionStore<{ foo: string }>();
const flow = defineCustomAgent<{ foo: string }>(
new Registry(),
{
name: 'heartbeatFreshTest',
store,
},
async (sess) => {
await sess.run(async () => {});
return { artifacts: [] };
}
);
const now = new Date().toISOString();
const snapshotId = await store.saveSnapshot(undefined, () => ({
createdAt: now,
updatedAt: now,
heartbeatAt: now,
status: 'pending',
state: { sessionId: 'sess-fresh', custom: { foo: 'bar' } },
}));
assert.ok(snapshotId);
const viaData = await flow.getSnapshotData({ snapshotId: snapshotId! });
assert.strictEqual(viaData?.status, 'pending');
});
it('does not expire a pending snapshot that has no heartbeat yet', async () => {
const store = new InMemorySessionStore<{ foo: string }>();
const flow = defineCustomAgent<{ foo: string }>(
new Registry(),
{
name: 'heartbeatNoneTest',
store,
},
async (sess) => {
await sess.run(async () => {});
return { artifacts: [] };
}
);
const old = new Date(Date.now() - 120_000).toISOString();
const snapshotId = await store.saveSnapshot(undefined, () => ({
createdAt: old,
updatedAt: old,
status: 'pending',
state: { sessionId: 'sess-noheartbeat', custom: { foo: 'bar' } },
}));
assert.ok(snapshotId);
const viaData = await flow.getSnapshotData({ snapshotId: snapshotId! });
assert.strictEqual(viaData?.status, 'pending');
});
it('should not override terminal status when aborting an already-completed flow', async () => {
const store = new InMemorySessionStore<{ foo: string }>();
const flow = defineCustomAgent<{ foo: string }>(
new Registry(),
{
name: 'abortDoneTest',
store,
},
async (sess) => {
await sess.run(async () => {});
return {
artifacts: [],
message: { role: 'model', content: [{ text: 'hi' }] },
};
}
);
const session = flow.streamBidi({});
session.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
});
session.close();
const output = await session.output;
assert.ok(output.snapshotId);
// Snapshot should be 'done' now
const snapBefore = await store.getSnapshot({
snapshotId: output.snapshotId!,
});
assert.strictEqual(snapBefore?.status, 'completed');
// Abort returns the previous status but does not override terminal states
const previousStatus = await flow.abort(output.snapshotId!);
assert.strictEqual(previousStatus, 'completed');
// Snapshot should still be 'done' - the mutator skips terminal states
const snapAfter = await store.getSnapshot({
snapshotId: output.snapshotId!,
});
assert.strictEqual(snapAfter?.status, 'completed');
});
it('should return undefined when aborting a non-existent snapshot', async () => {
const store = new InMemorySessionStore<{ foo: string }>();
const flow = defineCustomAgent<{ foo: string }>(
new Registry(),
{
name: 'abortMissingTest',
store,
},
async (sess) => {
await sess.run(async () => {});
return {
artifacts: [],
message: { role: 'model', content: [{ text: 'hi' }] },
};
}
);
const previousStatus = await flow.abort('non-existent-id');
assert.strictEqual(previousStatus, undefined);
});
it('should throw error when detach is requested without session store', async () => {
const flow = defineCustomAgent<{ foo: string }>(
new Registry(),
{
name: 'noStoreTest',
},
async (sess) => {
await sess.run(async () => {});
return {
artifacts: [],
message: { role: 'model', content: [{ text: 'hi' }] },
};
}
);
const session = flow.streamBidi({});
session.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
detach: true,
});
try {
await session.output;
assert.fail('Should have thrown error');
} catch (e: any) {
assert.strictEqual(
e.message,
'FAILED_PRECONDITION: Detach is only supported when a session store is provided.'
);
}
});
it('should save failed snapshot if detached flow throws', async () => {
const store = new InMemorySessionStore<{ foo: string }>();
let resolvePromise: () => void = () => {};
const releasePromise = new Promise<void>((resolve) => {
resolvePromise = resolve;
});
const flow = defineCustomAgent<{ foo: string }>(
new Registry(),
{
name: 'detachErrorTest',
store,
},
async (sess, { sendChunk }) => {
await sess.run(async () => {
await releasePromise;
throw new Error('intentional background failure');
});
return {
artifacts: [],
message: { role: 'model', content: [{ text: 'hi' }] },
};
}
);
const session = flow.streamBidi({});
session.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
detach: true,
});
const output = await session.output;
const snapshotId = output.snapshotId;
assert.ok(snapshotId);
resolvePromise();
session.close();
const snapFailed = await waitForSnapshotStatus(
store,
snapshotId!,
'failed'
);
assert.strictEqual(snapFailed.status, 'failed');
assert.strictEqual(
snapFailed.error?.message,
'intentional background failure'
);
});
it('should mark snapshot aborted even without subscription support', async () => {
const baseStore = new InMemorySessionStore();
const store = Object.assign(Object.create(baseStore), {
onSnapshotStateChange: undefined,
getSnapshot: baseStore.getSnapshot.bind(baseStore),
saveSnapshot: baseStore.saveSnapshot.bind(baseStore),
}) as InMemorySessionStore<any>;
let resolveBlock: () => void = () => {};
const blockPromise = new Promise<void>((resolve) => {
resolveBlock = resolve;
});
const flow = defineCustomAgent<{ foo: string }>(
new Registry(),
{
name: 'legacyStoreTest',
store,
},
async (sess, { sendChunk }) => {
await sess.run(async () => {
await blockPromise; // Keep flow pending until abort is called
});
return {
artifacts: [],
message: { role: 'model', content: [{ text: 'hi' }] },
};
}
);
const session = flow.streamBidi({});
session.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
detach: true,
});
const output = await session.output;
const snapshotId = output.snapshotId;
// Snapshot should be 'pending' since the flow is still blocked
const snapBefore = await store.getSnapshot({ snapshotId: snapshotId! });
assert.strictEqual(snapBefore?.status, 'pending');
await flow.abort(snapshotId!);
const snapshot = await store.getSnapshot({ snapshotId: snapshotId! });
assert.strictEqual(snapshot?.status, 'aborted');
// Release the flow so it doesn't hang
resolveBlock();
session.close();
});
it('should fetch snapshot data via companion action', async () => {
const store = new InMemorySessionStore<{ foo: string }>();
const flow = defineCustomAgent<{ foo: string }>(
new Registry(),
{
name: 'companionActionFlow',
store,
},
async (sess) => {
// Mutate session state so a snapshot is persisted on turn end.
await sess.run(async () => {});
return {
artifacts: [],
message: { role: 'model', content: [{ text: 'hi' }] },
};
}
);
const session = flow.streamBidi({});
session.send({
message: { role: 'user' as const, content: [{ text: 'hi' }] },
});
session.close();
const output = await session.output;
assert.ok(output.snapshotId, 'should have a snapshotId');
const snapData = await flow.getSnapshotData({
snapshotId: output.snapshotId!,
});
assert.strictEqual(snapData?.snapshotId, output.snapshotId);
});
it('should chain parentId properly across session snapshots', async () => {
const store = new InMemorySessionStore<{ foo: string }>();
const flow = defineCustomAgent<{ foo: string }>(
new Registry(),
{
name: 'lineageTest',
store,
},
async (sess) => {
await sess.run(async () => {});
return {
artifacts: [],
message: { role: 'model', content: [{ text: 'hi' }] },
};
}
);
const session1 = flow.streamBidi({});
session1.send({
message: { role: 'user' as const, content: [{ text: 'first' }] },