UNPKG

@genkit-ai/google-cloud

Version:

Genkit AI framework plugin for Google Cloud Platform including Firestore trace/state store and deployment helpers for Cloud Functions for Firebase.

919 lines (814 loc) 31.1 kB
/** * 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 { Firestore } from '@google-cloud/firestore'; import { afterEach, beforeEach, describe, it } from '@jest/globals'; import * as assert from 'assert'; import { AgentAPI, genkit, type SessionSnapshotInput } from 'genkit/beta'; import { FirestoreSessionStore, type FirestoreSessionStoreOptions, } from '../src/session-store/firestore'; interface Custom { counter?: number; notes?: string[]; } /** Builds a snapshot input with sensible defaults. */ function snapshot( overrides: Partial<SessionSnapshotInput<Custom>> & { state: SessionSnapshotInput<Custom>['state']; } ): SessionSnapshotInput<Custom> { return { createdAt: new Date().toISOString(), status: 'completed', ...overrides, }; } describe('FirestoreSessionStore', () => { let db: Firestore; let store: FirestoreSessionStore<Custom>; // Collections created during a test, cleaned up afterwards. We only delete // what this file created (not every project collection) so it can run in // parallel with the other emulator test files. let createdCollections: string[]; /** Creates a store and registers its collections for cleanup. */ function makeStore( opts?: Omit<FirestoreSessionStoreOptions, 'db' | 'collection'> & { collection?: string; } ): FirestoreSessionStore<Custom> { const collection = opts?.collection ?? `sessions-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; createdCollections.push( collection, `${collection}-pointers`, `${collection}-shards` ); return new FirestoreSessionStore<Custom>({ ...opts, db, collection, }); } /** The base collection name a store was configured with. */ function baseCollection(s: FirestoreSessionStore<Custom>): string { return (s as any).collection as string; } /** The (per-tenant) snapshots subcollection used by the store. */ function snapshotsCol(s: FirestoreSessionStore<Custom>, prefix = 'global') { return db.collection(baseCollection(s)).doc(prefix).collection('snapshots'); } /** The (per-tenant) shards subcollection used by the store. */ function shardsCol(s: FirestoreSessionStore<Custom>, prefix = 'global') { return db .collection(`${baseCollection(s)}-shards`) .doc(prefix) .collection('shards'); } /** The (per-tenant) pointers subcollection used by the store. */ function pointersCol(s: FirestoreSessionStore<Custom>, prefix = 'global') { return db .collection(`${baseCollection(s)}-pointers`) .doc(prefix) .collection('pointers'); } beforeEach(() => { process.env.FIRESTORE_EMULATOR_HOST = '127.0.0.1:8080'; db = new Firestore({ projectId: 'genkit-test' }); createdCollections = []; store = makeStore(); }); afterEach(async () => { for (const name of createdCollections) { await db.recursiveDelete(db.collection(name)); } await db.terminate(); }); it('saves a snapshot and reads it back by snapshotId', async () => { const id = await store.saveSnapshot(undefined, () => snapshot({ snapshotId: 'snap-1', state: { sessionId: 'sess-1', custom: { counter: 1 } }, }) ); assert.strictEqual(id, 'snap-1'); const read = await store.getSnapshot({ snapshotId: 'snap-1' }); assert.ok(read); assert.strictEqual(read.snapshotId, 'snap-1'); assert.strictEqual(read.state?.sessionId, 'sess-1'); assert.deepStrictEqual(read.state?.custom, { counter: 1 }); }); it('returns undefined for missing snapshot/session', async () => { assert.strictEqual( await store.getSnapshot({ snapshotId: 'nope' }), undefined ); assert.strictEqual( await store.getSnapshot({ sessionId: 'nope' }), undefined ); }); it('resolves the latest leaf snapshot by sessionId', async () => { await store.saveSnapshot('s1', () => snapshot({ snapshotId: 's1', state: { sessionId: 'sess-2', custom: { counter: 1 } }, }) ); await store.saveSnapshot('s2', () => snapshot({ snapshotId: 's2', parentId: 's1', state: { sessionId: 'sess-2', custom: { counter: 2 } }, }) ); const leaf = await store.getSnapshot({ sessionId: 'sess-2' }); assert.ok(leaf); assert.strictEqual(leaf.snapshotId, 's2'); assert.deepStrictEqual(leaf.state?.custom, { counter: 2 }); }); it('reconstructs full state from a chain of diffs', async () => { await store.saveSnapshot('c1', () => snapshot({ snapshotId: 'c1', state: { sessionId: 'sess-3', custom: { counter: 1, notes: ['a'] }, }, }) ); await store.saveSnapshot('c2', () => snapshot({ snapshotId: 'c2', parentId: 'c1', state: { sessionId: 'sess-3', custom: { counter: 2, notes: ['a', 'b'] }, }, }) ); await store.saveSnapshot('c3', () => snapshot({ snapshotId: 'c3', parentId: 'c2', state: { sessionId: 'sess-3', custom: { counter: 3, notes: ['a', 'b', 'c'] }, }, }) ); // Middle snapshot reconstructs correctly. const mid = await store.getSnapshot({ snapshotId: 'c2' }); assert.deepStrictEqual(mid?.state?.custom, { counter: 2, notes: ['a', 'b'], }); // Leaf reconstructs correctly via sessionId. const leaf = await store.getSnapshot({ sessionId: 'sess-3' }); assert.deepStrictEqual(leaf?.state?.custom, { counter: 3, notes: ['a', 'b', 'c'], }); // The stored document only persists a diff, not the full state. const raw = await snapshotsCol(store).doc('c3').get(); const data = raw.data(); assert.ok(Array.isArray(data?.statePatch)); assert.strictEqual(data?.state, undefined); }); it('passes the current snapshot to the mutator on upsert', async () => { await store.saveSnapshot('u1', () => snapshot({ snapshotId: 'u1', status: 'pending', state: { sessionId: 'sess-4', custom: { counter: 1 } }, }) ); let seenStatus: string | undefined; await store.saveSnapshot('u1', (current) => { seenStatus = current?.status; return { ...current!, status: 'completed' }; }); assert.strictEqual(seenStatus, 'pending'); const read = await store.getSnapshot({ snapshotId: 'u1' }); assert.strictEqual(read?.status, 'completed'); }); it('skips the write when the mutator returns null', async () => { const result = await store.saveSnapshot('missing', (current) => current ? { ...current } : null ); assert.strictEqual(result, null); assert.strictEqual( await store.getSnapshot({ snapshotId: 'missing' }), undefined ); }); it('handles branching: latest leaf wins by pointer', async () => { await store.saveSnapshot('b1', () => snapshot({ snapshotId: 'b1', state: { sessionId: 'sess-5', custom: { counter: 1 } }, }) ); // Two children of b1 (a branch, e.g. regenerate). await store.saveSnapshot('b2a', () => snapshot({ snapshotId: 'b2a', parentId: 'b1', state: { sessionId: 'sess-5', custom: { counter: 20 } }, }) ); await store.saveSnapshot('b2b', () => snapshot({ snapshotId: 'b2b', parentId: 'b1', state: { sessionId: 'sess-5', custom: { counter: 21 } }, }) ); // Pointer tracks the most recently created leaf. const leaf = await store.getSnapshot({ sessionId: 'sess-5' }); assert.strictEqual(leaf?.snapshotId, 'b2b'); // Both branches remain independently addressable. const a = await store.getSnapshot({ snapshotId: 'b2a' }); assert.deepStrictEqual(a?.state?.custom, { counter: 20 }); }); it('aborting an existing snapshot does not move the leaf pointer', async () => { await store.saveSnapshot('a1', () => snapshot({ snapshotId: 'a1', status: 'pending', state: { sessionId: 'sess-6', custom: { counter: 1 } }, }) ); await store.saveSnapshot('a1', (current) => ({ ...current!, status: 'aborted', })); const leaf = await store.getSnapshot({ sessionId: 'sess-6' }); assert.strictEqual(leaf?.snapshotId, 'a1'); assert.strictEqual(leaf?.status, 'aborted'); }); it('notifies onSnapshotStateChange listeners on status change', async () => { await store.saveSnapshot('w1', () => snapshot({ snapshotId: 'w1', status: 'pending', state: { sessionId: 'sess-7', custom: { counter: 1 } }, }) ); const aborted = new Promise<void>((resolve) => { const unsubscribe = store.onSnapshotStateChange!('w1', (snap) => { if (snap.status === 'aborted') { unsubscribe?.(); resolve(); } }); }); await store.saveSnapshot('w1', (current) => ({ ...current!, status: 'aborted', })); await aborted; }); it('creates periodic checkpoints and reconstructs across them', async () => { // A small interval forces several checkpoints over a long linear chain. const cpStore = makeStore({ checkpointInterval: 5 }); const turns = 23; let parentId: string | undefined; for (let i = 0; i < turns; i++) { const id = `t${i}`; await cpStore.saveSnapshot(id, () => snapshot({ snapshotId: id, parentId, state: { sessionId: 'long', custom: { counter: i, notes: Array.from({ length: i + 1 }, (_, j) => `n${j}`), }, }, }) ); parentId = id; } // Leaf reconstructs the full accumulated state. const leaf = await cpStore.getSnapshot({ sessionId: 'long' }); assert.strictEqual(leaf?.snapshotId, `t${turns - 1}`); assert.strictEqual(leaf?.state?.custom?.counter, turns - 1); assert.strictEqual(leaf?.state?.custom?.notes?.length, turns); // An arbitrary middle snapshot reconstructs correctly across a checkpoint. const mid = await cpStore.getSnapshot({ snapshotId: 't12' }); assert.strictEqual(mid?.state?.custom?.counter, 12); assert.strictEqual(mid?.state?.custom?.notes?.length, 13); // Several documents were promoted to checkpoints (root + every 5 turns). const all = await snapshotsCol(cpStore) .where('kind', '==', 'checkpoint') .get(); assert.ok( all.size >= turns / 5, `expected multiple checkpoints, got ${all.size}` ); }); it('shards large checkpoint state across multiple documents', async () => { // Tiny shard size to force multi-shard storage of a modest state. const shardStore = makeStore({ shardSize: 256 }); const notes = Array.from({ length: 200 }, (_, i) => `note-number-${i}`); await shardStore.saveSnapshot('big', () => snapshot({ snapshotId: 'big', state: { sessionId: 'sess-shard', custom: { counter: 1, notes } }, }) ); // Round-trips correctly despite being split into many shards. const read = await shardStore.getSnapshot({ snapshotId: 'big' }); assert.deepStrictEqual(read?.state?.custom?.notes, notes); // The state really was sharded across more than one document. const shards = await shardsCol(shardStore).get(); assert.ok(shards.size > 1, `expected multiple shards, got ${shards.size}`); }); it('does not cache full state in the pointer document', async () => { await store.saveSnapshot('p1', () => snapshot({ snapshotId: 'p1', state: { sessionId: 'sess-ptr', custom: { counter: 1 } }, }) ); const pointer = await pointersCol(store).doc('sess-ptr').get(); const data = pointer.data(); assert.strictEqual(data?.currentSnapshotId, 'p1'); assert.strictEqual(data?.currentState, undefined); assert.strictEqual(typeof data?.checkpointId, 'string'); }); it('rejects getSnapshot with both or neither id', async () => { await assert.rejects( store.getSnapshot({ snapshotId: 'x', sessionId: 'y' }), /exactly one/ ); await assert.rejects(store.getSnapshot({}), /exactly one/); }); it('rejects getSnapshot with a blank id', async () => { // A whitespace-only id is treated as absent and surfaced as a clear // INVALID_ARGUMENT rather than being used as an (unusable) document key. await assert.rejects( store.getSnapshot({ sessionId: ' ' }), /exactly one/ ); await assert.rejects(store.getSnapshot({ snapshotId: '' }), /exactly one/); }); it('rejects saveSnapshot when sessionId is missing', async () => { await assert.rejects( store.saveSnapshot('no-sess', () => snapshot({ snapshotId: 'no-sess', state: { custom: { counter: 1 } } }) ), /sessionId/ ); }); it('mints a UUID when neither call nor result supplies a snapshotId', async () => { const id = await store.saveSnapshot(undefined, () => snapshot({ state: { sessionId: 'sess-uuid', custom: { counter: 1 } } }) ); assert.ok(id, 'expected a generated id'); // A v4 UUID, not one we supplied. assert.match( id!, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ ); const read = await store.getSnapshot({ snapshotId: id! }); assert.strictEqual(read?.state?.custom?.counter, 1); }); it('promotes an oversized diff to a sharded checkpoint', async () => { // A shard size small enough that a single turn's diff exceeds it, forcing // the diff -> checkpoint promotion path. A large interval ensures the turn // would otherwise be a plain diff. const promoteStore = makeStore({ shardSize: 256, checkpointInterval: 100 }); await promoteStore.saveSnapshot('d1', () => snapshot({ snapshotId: 'd1', state: { sessionId: 'sess-promote', custom: { counter: 0 } }, }) ); // A child whose new state adds a large blob: the resulting diff is bigger // than shardSize and must be stored as a checkpoint instead. const bigNotes = Array.from({ length: 300 }, (_, i) => `note-${i}`); await promoteStore.saveSnapshot('d2', () => snapshot({ snapshotId: 'd2', parentId: 'd1', state: { sessionId: 'sess-promote', custom: { counter: 1, notes: bigNotes }, }, }) ); // Round-trips correctly. const read = await promoteStore.getSnapshot({ snapshotId: 'd2' }); assert.deepStrictEqual(read?.state?.custom?.notes, bigNotes); // The promoted document is a checkpoint with no statePatch. const raw = await snapshotsCol(promoteStore).doc('d2').get(); assert.strictEqual(raw.data()?.kind, 'checkpoint'); assert.strictEqual(raw.data()?.statePatch, undefined); }); it('promotes an oversized diff upsert to a sharded checkpoint', async () => { // A leaf that starts as a small diff can grow past the limit on an // in-place rewrite; the upsert path must promote it to a checkpoint rather // than write an oversized statePatch. const promoteStore = makeStore({ shardSize: 256, checkpointInterval: 100 }); await promoteStore.saveSnapshot('e1', () => snapshot({ snapshotId: 'e1', state: { sessionId: 'sess-upsert-promote', custom: { counter: 0 } }, }) ); // A small child diff (well under shardSize). await promoteStore.saveSnapshot('e2', () => snapshot({ snapshotId: 'e2', parentId: 'e1', state: { sessionId: 'sess-upsert-promote', custom: { counter: 1 }, }, }) ); // It started life as a diff. const before = await snapshotsCol(promoteStore).doc('e2').get(); assert.strictEqual(before.data()?.kind, 'diff'); // Rewrite the leaf in place with a much larger state: the diff would now // exceed shardSize and must be promoted to a sharded checkpoint. const bigNotes = Array.from({ length: 300 }, (_, i) => `note-${i}`); await promoteStore.saveSnapshot('e2', (current) => ({ ...current!, state: { sessionId: 'sess-upsert-promote', custom: { counter: 2, notes: bigNotes }, }, })); // Round-trips correctly. const read = await promoteStore.getSnapshot({ snapshotId: 'e2' }); assert.deepStrictEqual(read?.state?.custom?.notes, bigNotes); assert.strictEqual(read?.state?.custom?.counter, 2); // The promoted document is now a checkpoint with no statePatch, and its // state was sharded across more than one document. const after = await snapshotsCol(promoteStore).doc('e2').get(); assert.strictEqual(after.data()?.kind, 'checkpoint'); assert.strictEqual(after.data()?.statePatch, undefined); assert.ok( after.data()?.checkpointShardCount > 1, `expected multiple shards, got ${after.data()?.checkpointShardCount}` ); }); it('deletes stale trailing shards when a re-checkpoint shrinks', async () => { const shrinkStore = makeStore({ shardSize: 64 }); // Initial large state spanning several shards. const bigNotes = Array.from({ length: 100 }, (_, i) => `note-${i}`); await shrinkStore.saveSnapshot('k1', () => snapshot({ snapshotId: 'k1', state: { sessionId: 'sess-shrink', custom: { counter: 1, notes: bigNotes }, }, }) ); const before = await shardsCol(shrinkStore).get(); assert.ok(before.size > 1, `expected multiple shards, got ${before.size}`); // Upsert the same (leaf, checkpoint) snapshot with a much smaller state. await shrinkStore.saveSnapshot('k1', (current) => ({ ...current!, state: { sessionId: 'sess-shrink', custom: { counter: 2 } }, })); const after = await shardsCol(shrinkStore).get(); assert.ok( after.size < before.size, `expected fewer shards after shrink (before=${before.size}, after=${after.size})` ); // And it still reads back correctly with no leftover/corrupt shards. const read = await shrinkStore.getSnapshot({ snapshotId: 'k1' }); assert.strictEqual(read?.state?.custom?.counter, 2); assert.strictEqual(read?.state?.custom?.notes, undefined); }); it('treats a snapshot with an orphaned parent as a fresh checkpoint', async () => { await store.saveSnapshot('o1', () => snapshot({ snapshotId: 'o1', parentId: 'does-not-exist', state: { sessionId: 'sess-orphan', custom: { counter: 7 } }, }) ); const read = await store.getSnapshot({ snapshotId: 'o1' }); assert.strictEqual(read?.state?.custom?.counter, 7); // It was stored as a checkpoint (root of a new chain), not a diff. const raw = await snapshotsCol(store).doc('o1').get(); assert.strictEqual(raw.data()?.kind, 'checkpoint'); }); it('persists and round-trips finishReason, error, heartbeatAt, and updatedAt', async () => { const createdAt = new Date('2026-01-01T00:00:00.000Z').toISOString(); const heartbeatAt = new Date('2026-01-01T00:00:05.000Z').toISOString(); await store.saveSnapshot('f1', () => ({ snapshotId: 'f1', createdAt, status: 'pending', finishReason: 'interrupted', heartbeatAt, error: { status: 'INTERNAL', message: 'boom', details: { code: 42 } }, state: { sessionId: 'sess-fields', custom: { counter: 1 } }, })); const read = await store.getSnapshot({ snapshotId: 'f1' }); assert.strictEqual(read?.status, 'pending'); assert.strictEqual(read?.finishReason, 'interrupted'); assert.strictEqual(read?.heartbeatAt, heartbeatAt); assert.deepStrictEqual(read?.error, { status: 'INTERNAL', message: 'boom', details: { code: 42 }, }); // updatedAt defaults to createdAt until the snapshot is rewritten. assert.strictEqual(read?.updatedAt, createdAt); // Upsert with an explicit, later updatedAt and a refreshed heartbeat. const updatedAt = new Date('2026-01-02T00:00:00.000Z').toISOString(); const heartbeatAt2 = new Date('2026-01-02T00:00:05.000Z').toISOString(); await store.saveSnapshot('f1', (current) => ({ ...current!, updatedAt, heartbeatAt: heartbeatAt2, status: 'completed', })); const read2 = await store.getSnapshot({ snapshotId: 'f1' }); assert.strictEqual(read2?.updatedAt, updatedAt); assert.strictEqual(read2?.createdAt, createdAt); assert.strictEqual(read2?.heartbeatAt, heartbeatAt2); }); it('diffs and reconstructs messages and artifacts across a chain', async () => { await store.saveSnapshot('m1', () => snapshot({ snapshotId: 'm1', state: { sessionId: 'sess-msgs', messages: [{ role: 'user', content: [{ text: 'hi' }] }], artifacts: [{ name: 'a', parts: [{ text: 'one' }] }], }, }) ); await store.saveSnapshot('m2', () => snapshot({ snapshotId: 'm2', parentId: 'm1', state: { sessionId: 'sess-msgs', messages: [ { role: 'user', content: [{ text: 'hi' }] }, { role: 'model', content: [{ text: 'hello' }] }, ], artifacts: [ { name: 'a', parts: [{ text: 'one' }] }, { name: 'b', parts: [{ text: 'two' }] }, ], }, }) ); const leaf = await store.getSnapshot({ sessionId: 'sess-msgs' }); assert.strictEqual(leaf?.state?.messages?.length, 2); assert.strictEqual(leaf?.state?.messages?.[1].role, 'model'); assert.strictEqual(leaf?.state?.artifacts?.length, 2); assert.strictEqual(leaf?.state?.artifacts?.[1].name, 'b'); // The child stored only a diff, not the full message array. const raw = await snapshotsCol(store).doc('m2').get(); assert.strictEqual(raw.data()?.kind, 'diff'); assert.ok(Array.isArray(raw.data()?.statePatch)); }); it('throws DATA_LOSS when a checkpoint shard is missing', async () => { await store.saveSnapshot('g1', () => snapshot({ snapshotId: 'g1', state: { sessionId: 'sess-dataloss', custom: { counter: 1 } }, }) ); // Manually delete the checkpoint's only shard to simulate corruption. await shardsCol(store).doc('g1_0').delete(); await assert.rejects( store.getSnapshot({ snapshotId: 'g1' }), /missing checkpoint shard/ ); }); it('treats every snapshot as a checkpoint when checkpointInterval is 1', async () => { const cp1Store = makeStore({ checkpointInterval: 1 }); let parentId: string | undefined; for (let i = 0; i < 4; i++) { const id = `i${i}`; await cp1Store.saveSnapshot(id, () => snapshot({ snapshotId: id, parentId, state: { sessionId: 'sess-cp1', custom: { counter: i } }, }) ); parentId = id; } const leaf = await cp1Store.getSnapshot({ sessionId: 'sess-cp1' }); assert.strictEqual(leaf?.state?.custom?.counter, 3); // Every document is a checkpoint; none is a diff. const diffs = await snapshotsCol(cp1Store) .where('kind', '==', 'diff') .get(); assert.strictEqual(diffs.size, 0); }); describe('snapshotPathPrefix (multi-tenant isolation)', () => { // A prefix derived from the call context's user id, mirroring how an app // would scope snapshots to the authenticated user. const byUser = (options?: { context?: { auth?: { uid?: string } } }) => options?.context?.auth?.uid ?? 'anonymous'; const ctx = (uid: string) => ({ context: { auth: { uid } } }); it('isolates snapshots per tenant: a snapshotId is not readable across tenants', async () => { const tenantStore = makeStore({ snapshotPathPrefix: byUser }); await tenantStore.saveSnapshot( 'shared-id', () => snapshot({ snapshotId: 'shared-id', state: { sessionId: 'sess-a', custom: { counter: 1 } }, }), ctx('alice') ); // Alice can read her own snapshot. const asAlice = await tenantStore.getSnapshot({ snapshotId: 'shared-id', context: ctx('alice').context, }); assert.strictEqual(asAlice?.state?.custom?.counter, 1); // Bob, with the very same snapshotId, sees nothing. const asBob = await tenantStore.getSnapshot({ snapshotId: 'shared-id', context: ctx('bob').context, }); assert.strictEqual(asBob, undefined); }); it('isolates sessionId lookups per tenant', async () => { const tenantStore = makeStore({ snapshotPathPrefix: byUser }); await tenantStore.saveSnapshot( 'a1', () => snapshot({ snapshotId: 'a1', state: { sessionId: 'shared-sess', custom: { counter: 11 } }, }), ctx('alice') ); // Same sessionId, different tenant: no leaf. const asBob = await tenantStore.getSnapshot({ sessionId: 'shared-sess', context: ctx('bob').context, }); assert.strictEqual(asBob, undefined); // Alice resolves her own leaf. const asAlice = await tenantStore.getSnapshot({ sessionId: 'shared-sess', context: ctx('alice').context, }); assert.strictEqual(asAlice?.snapshotId, 'a1'); assert.strictEqual(asAlice?.state?.custom?.counter, 11); }); it('writes documents under the tenant-scoped subcollection', async () => { const tenantStore = makeStore({ snapshotPathPrefix: byUser }); await tenantStore.saveSnapshot( 'doc-1', () => snapshot({ snapshotId: 'doc-1', state: { sessionId: 'sess-scope', custom: { counter: 1 } }, }), ctx('carol') ); // Present under carol's prefix. const scoped = await snapshotsCol(tenantStore, 'carol') .doc('doc-1') .get(); assert.ok(scoped.exists); // Absent under the default 'global' prefix. const global = await snapshotsCol(tenantStore, 'global') .doc('doc-1') .get(); assert.strictEqual(global.exists, false); }); }); describe('agent integration (defineAgent + echo model)', () => { /** * Creates a Firestore-backed store on a fresh collection and registers it * for cleanup. Returns the untyped store (State = unknown) suitable for * wiring into an agent. */ function makeAgentStore(): FirestoreSessionStore { const collection = `agent-sessions-${Date.now()}-${Math.floor( Math.random() * 1e6 )}`; createdCollections.push( collection, `${collection}-pointers`, `${collection}-shards` ); return new FirestoreSessionStore({ db, collection }); } /** * Defines a genkit/beta instance with a simple echo model that replies * with "echo: <last user text>". */ function makeAi() { const ai = genkit({}); ai.defineModel({ name: 'echo' }, async (request) => { const lastUser = [...request.messages] .reverse() .find((m) => m.role === 'user'); const text = lastUser?.content.map((p) => (p as any).text ?? '').join('') ?? ''; return { message: { role: 'model', content: [{ text: `echo: ${text}` }] }, finishReason: 'stop', }; }); return ai; } /** Drives a single agent turn to completion and returns its output. */ async function runTurn( agent: AgentAPI, input: { message: { role: 'user'; content: { text: string }[] } }, init: Record<string, unknown> = {} ) { const session = agent.chat(init); const { stream, response } = session.sendStream(input); for await (const _ of stream) { // drain the stream } return { response: await response, snapshotId: session.snapshotId, sessionId: session.sessionId, }; } it('persists a multi-turn conversation through Firestore via the agent API', async () => { const agentStore = makeAgentStore(); const ai = makeAi(); const agent = ai.defineAgent({ name: 'echoAgent', model: 'echo', system: 'You are an echo bot.', store: agentStore, }); // Turn 1: a fresh, server-managed session. const { snapshotId: snapshotId1, sessionId } = await runTurn(agent, { message: { role: 'user', content: [{ text: 'hello' }] }, }); assert.ok(sessionId, 'turn 1 should mint a sessionId'); assert.ok(snapshotId1, 'turn 1 should persist a snapshot'); // The snapshot is readable straight from Firestore and contains the // accumulated [user, model] history with the echoed reply. const snap1 = await agentStore.getSnapshot({ snapshotId: snapshotId1 }); assert.ok(snap1, 'turn 1 snapshot should exist in Firestore'); assert.strictEqual(snap1!.state?.sessionId, sessionId); const msgs1 = snap1!.state?.messages ?? []; assert.strictEqual(msgs1.length, 2); assert.strictEqual(msgs1[0].role, 'user'); assert.strictEqual(msgs1[0].content[0].text, 'hello'); assert.strictEqual(msgs1[1].role, 'model'); assert.strictEqual(msgs1[1].content[0].text, 'echo: hello'); // Turn 2: resume from the persisted snapshot. History should accumulate. const { response: out2 } = await runTurn( agent, { message: { role: 'user', content: [{ text: 'world' }] } }, { snapshotId: snapshotId1 } ); assert.strictEqual( out2.sessionId, sessionId, 'turn 2 should preserve the sessionId across turns' ); // Resolving the latest leaf by sessionId reflects the full conversation. const leaf = await agentStore.getSnapshot({ sessionId }); assert.ok(leaf, 'a leaf snapshot should be resolvable by sessionId'); const msgs2 = leaf!.state?.messages ?? []; assert.strictEqual(msgs2.length, 4, 'history should accumulate to 4'); assert.deepStrictEqual( msgs2.map((m) => m.content.map((p: any) => p.text).join('')), ['hello', 'echo: hello', 'world', 'echo: world'] ); // System messages are not persisted into stored history. assert.strictEqual( msgs2.filter((m) => m.role === 'system').length, 0, 'system messages should not be persisted' ); }); }); });