@genkit-ai/ai
Version:
Genkit AI framework generative AI APIs.
631 lines (550 loc) • 20.7 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 * as assert from 'assert';
import * as fs from 'fs';
import { describe, it } from 'node:test';
import * as os from 'os';
import * as path from 'path';
import {
FileSessionStore,
InMemorySessionStore,
} from '../src/session-stores.js';
import { reserveSnapshotId, type SessionSnapshot } from '../src/session.js';
initNodeFeatures();
describe('InMemorySessionStore', () => {
it('should save and get snapshots', async () => {
const store = new InMemorySessionStore<{ foo: string }>();
const snapshot = {
snapshotId: 'snap-123',
createdAt: new Date().toISOString(),
state: { custom: { foo: 'bar' } },
};
await store.saveSnapshot('snap-123', () => snapshot);
const got = await store.getSnapshot({ snapshotId: 'snap-123' });
assert.deepStrictEqual(got, snapshot);
});
it('should return undefined for missing snapshot', async () => {
const store = new InMemorySessionStore();
const got = await store.getSnapshot({ snapshotId: 'missing' });
assert.strictEqual(got, undefined);
});
it('should deep copy on save and get', async () => {
const store = new InMemorySessionStore<{ foo: string }>();
const state = { foo: 'bar' };
const snapshot = {
snapshotId: 'snap-123',
createdAt: new Date().toISOString(),
state: { custom: state },
};
await store.saveSnapshot('snap-123', () => snapshot);
// Mutate local state
state.foo = 'baz';
const got = await store.getSnapshot({ snapshotId: 'snap-123' });
assert.strictEqual(got?.state.custom?.foo, 'bar');
});
it('resolves the latest leaf snapshot by sessionId', async () => {
const store = new InMemorySessionStore();
const sessionId = globalThis.crypto.randomUUID();
const first = reserveSnapshotId();
const second = reserveSnapshotId();
await store.saveSnapshot(first, () => ({
snapshotId: first,
createdAt: new Date().toISOString(),
status: 'completed' as const,
state: { sessionId },
}));
await store.saveSnapshot(second, () => ({
snapshotId: second,
parentId: first,
createdAt: new Date().toISOString(),
status: 'completed' as const,
state: { sessionId },
}));
const leaf = await store.getSnapshot({ sessionId });
assert.strictEqual(leaf?.snapshotId, second);
});
});
describe('FileSessionStore', () => {
function tmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'genkit-file-store-'));
}
function makeSnapshot(
snapshotId: string,
sessionId: string,
parentId?: string
): SessionSnapshot {
return {
snapshotId,
parentId,
createdAt: new Date().toISOString(),
status: 'completed',
state: { sessionId },
};
}
it('stores each snapshot as a flat <snapshotId>.json file', async () => {
const dir = tmpDir();
const store = new FileSessionStore(dir);
const snapshotId = reserveSnapshotId();
const sessionId = globalThis.crypto.randomUUID();
await store.saveSnapshot(snapshotId, () =>
makeSnapshot(snapshotId, sessionId)
);
// The file lives flat under the default "global" prefix dir.
const filePath = path.join(dir, 'global', `${snapshotId}.json`);
assert.ok(fs.existsSync(filePath), `expected file at ${filePath}`);
const got = await store.getSnapshot({ snapshotId });
assert.strictEqual(got?.snapshotId, snapshotId);
assert.strictEqual(got?.state.sessionId, sessionId);
});
it('resolves the latest leaf snapshot by sessionId', async () => {
const dir = tmpDir();
const store = new FileSessionStore(dir);
const sessionId = globalThis.crypto.randomUUID();
const first = reserveSnapshotId();
const second = reserveSnapshotId();
await store.saveSnapshot(first, () => makeSnapshot(first, sessionId));
await store.saveSnapshot(second, () =>
makeSnapshot(second, sessionId, first)
);
const leaf = await store.getSnapshot({ sessionId });
assert.strictEqual(leaf?.snapshotId, second);
});
it('returns the latest leaf for a branching history by default', async () => {
const dir = tmpDir();
const store = new FileSessionStore(dir);
const sessionId = globalThis.crypto.randomUUID();
const root = reserveSnapshotId();
const branchA = reserveSnapshotId();
const branchB = reserveSnapshotId();
await store.saveSnapshot(root, () => ({
...makeSnapshot(root, sessionId),
createdAt: '2026-01-01T00:00:00.000Z',
}));
await store.saveSnapshot(branchA, () => ({
...makeSnapshot(branchA, sessionId, root),
createdAt: '2026-01-01T00:00:01.000Z',
}));
await store.saveSnapshot(branchB, () => ({
...makeSnapshot(branchB, sessionId, root),
createdAt: '2026-01-01T00:00:02.000Z',
}));
// By default a branched lookup resolves to the most-recently created leaf.
const leaf = await store.getSnapshot({ sessionId });
assert.strictEqual(leaf?.snapshotId, branchB);
});
it('throws on branching history when rejectBranchingSessions is enabled', async () => {
const dir = tmpDir();
const store = new FileSessionStore(dir, { rejectBranchingSessions: true });
const sessionId = globalThis.crypto.randomUUID();
const root = reserveSnapshotId();
const branchA = reserveSnapshotId();
const branchB = reserveSnapshotId();
await store.saveSnapshot(root, () => makeSnapshot(root, sessionId));
await store.saveSnapshot(branchA, () =>
makeSnapshot(branchA, sessionId, root)
);
await store.saveSnapshot(branchB, () =>
makeSnapshot(branchB, sessionId, root)
);
await assert.rejects(
() => store.getSnapshot({ sessionId }),
/branching snapshots/
);
});
it('isolates snapshots per tenant via snapshotPathPrefix', async () => {
const dir = tmpDir();
const store = new FileSessionStore(dir, {
// Derive the tenant prefix from the auth context (e.g. user id).
snapshotPathPrefix: (options) =>
(options?.context?.auth as any)?.uid ?? 'anon',
});
const sessionId = globalThis.crypto.randomUUID();
const aliceSnap = reserveSnapshotId();
const bobSnap = reserveSnapshotId();
const aliceCtx = { context: { auth: { uid: 'alice' } } };
const bobCtx = { context: { auth: { uid: 'bob' } } };
await store.saveSnapshot(
aliceSnap,
() => makeSnapshot(aliceSnap, sessionId),
aliceCtx
);
await store.saveSnapshot(
bobSnap,
() => makeSnapshot(bobSnap, sessionId),
bobCtx
);
// Each tenant gets its own sub-directory.
assert.ok(
fs.existsSync(path.join(dir, 'alice', `${aliceSnap}.json`)),
"alice's snapshot should be under her prefix"
);
assert.ok(
fs.existsSync(path.join(dir, 'bob', `${bobSnap}.json`)),
"bob's snapshot should be under his prefix"
);
// A tenant can only see snapshots scoped to their own prefix.
assert.ok(await store.getSnapshot({ snapshotId: aliceSnap, ...aliceCtx }));
assert.strictEqual(
await store.getSnapshot({ snapshotId: aliceSnap, ...bobCtx }),
undefined
);
// sessionId lookups are likewise scoped per tenant.
assert.strictEqual(
(await store.getSnapshot({ sessionId, ...aliceCtx }))?.snapshotId,
aliceSnap
);
assert.strictEqual(
(await store.getSnapshot({ sessionId, ...bobCtx }))?.snapshotId,
bobSnap
);
});
it('prunes snapshots beyond maxPersistedChainLength', async () => {
const dir = tmpDir();
const store = new FileSessionStore(dir, { maxPersistedChainLength: 2 });
const sessionId = globalThis.crypto.randomUUID();
const a = reserveSnapshotId();
const b = reserveSnapshotId();
const c = reserveSnapshotId();
await store.saveSnapshot(a, () => makeSnapshot(a, sessionId));
await store.saveSnapshot(b, () => makeSnapshot(b, sessionId, a));
await store.saveSnapshot(c, () => makeSnapshot(c, sessionId, b));
// Only the two most recent snapshots in the chain are retained.
assert.ok(await store.getSnapshot({ snapshotId: c }));
assert.ok(await store.getSnapshot({ snapshotId: b }));
assert.strictEqual(await store.getSnapshot({ snapshotId: a }), undefined);
});
describe('session pointer', () => {
it('writes a pointer file tracking the current leaf', async () => {
const dir = tmpDir();
const store = new FileSessionStore(dir);
const sessionId = globalThis.crypto.randomUUID();
const first = reserveSnapshotId();
const second = reserveSnapshotId();
await store.saveSnapshot(first, () => makeSnapshot(first, sessionId));
await store.saveSnapshot(second, () =>
makeSnapshot(second, sessionId, first)
);
const pointerPath = path.join(
dir,
'global',
'.pointers',
`${sessionId}.json`
);
assert.ok(
fs.existsSync(pointerPath),
`expected pointer file at ${pointerPath}`
);
const pointer = JSON.parse(fs.readFileSync(pointerPath, 'utf-8'));
assert.strictEqual(pointer.currentSnapshotId, second);
});
it('resolves by sessionId via the pointer fast path', async () => {
const dir = tmpDir();
const store = new FileSessionStore(dir);
const sessionId = globalThis.crypto.randomUUID();
const first = reserveSnapshotId();
const second = reserveSnapshotId();
await store.saveSnapshot(first, () => makeSnapshot(first, sessionId));
await store.saveSnapshot(second, () =>
makeSnapshot(second, sessionId, first)
);
// Corrupt a non-leaf snapshot. The pointer fast path reads only the leaf,
// so the lookup must still succeed without scanning every file.
fs.writeFileSync(
path.join(dir, 'global', `${first}.json`),
'not json',
'utf-8'
);
const leaf = await store.getSnapshot({ sessionId });
assert.strictEqual(leaf?.snapshotId, second);
});
it('self-heals a missing pointer via the scan and rewrites it', async () => {
const dir = tmpDir();
const store = new FileSessionStore(dir);
const sessionId = globalThis.crypto.randomUUID();
const first = reserveSnapshotId();
const second = reserveSnapshotId();
await store.saveSnapshot(first, () => makeSnapshot(first, sessionId));
await store.saveSnapshot(second, () =>
makeSnapshot(second, sessionId, first)
);
const pointerPath = path.join(
dir,
'global',
'.pointers',
`${sessionId}.json`
);
fs.rmSync(pointerPath);
// Lookup still resolves the leaf by scanning...
const leaf = await store.getSnapshot({ sessionId });
assert.strictEqual(leaf?.snapshotId, second);
// ...and the pointer is rewritten for subsequent fast-path lookups.
assert.ok(fs.existsSync(pointerPath));
const pointer = JSON.parse(fs.readFileSync(pointerPath, 'utf-8'));
assert.strictEqual(pointer.currentSnapshotId, second);
});
it('falls back to the scan when the pointer is stale', async () => {
const dir = tmpDir();
const store = new FileSessionStore(dir);
const sessionId = globalThis.crypto.randomUUID();
const first = reserveSnapshotId();
const second = reserveSnapshotId();
await store.saveSnapshot(first, () => makeSnapshot(first, sessionId));
await store.saveSnapshot(second, () =>
makeSnapshot(second, sessionId, first)
);
// Point at a snapshot that no longer exists.
const pointerPath = path.join(
dir,
'global',
'.pointers',
`${sessionId}.json`
);
fs.writeFileSync(
pointerPath,
JSON.stringify({
currentSnapshotId: 'does-not-exist',
updatedAt: new Date().toISOString(),
}),
'utf-8'
);
const leaf = await store.getSnapshot({ sessionId });
assert.strictEqual(leaf?.snapshotId, second);
// The stale pointer is refreshed to the real leaf.
const pointer = JSON.parse(fs.readFileSync(pointerPath, 'utf-8'));
assert.strictEqual(pointer.currentSnapshotId, second);
});
it('does not write a pointer into the snapshot scan space', async () => {
const dir = tmpDir();
const store = new FileSessionStore(dir);
const sessionId = globalThis.crypto.randomUUID();
const snapshotId = reserveSnapshotId();
await store.saveSnapshot(snapshotId, () =>
makeSnapshot(snapshotId, sessionId)
);
// The pointer lives in a hidden sub-directory, not alongside snapshots,
// so the leaf scan (which only reads *.json files) never sees it.
const files = fs.readdirSync(path.join(dir, 'global'));
assert.deepStrictEqual(
files.filter((f) => f.endsWith('.json')),
[`${snapshotId}.json`]
);
});
});
describe('onSnapshotStateChange', () => {
/**
* Resolves once `callback` observes a snapshot matching `predicate`, or
* rejects after `timeoutMs`. Always unsubscribes before settling.
*/
function waitForSnapshot(
store: FileSessionStore,
snapshotId: string,
predicate: (snap: SessionSnapshot) => boolean,
options?: { context?: any },
timeoutMs = 5000
): Promise<SessionSnapshot> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (typeof unsubscribe === 'function') unsubscribe();
reject(
new Error(
`Timed out waiting for snapshot ${snapshotId} to match predicate`
)
);
}, timeoutMs);
const unsubscribe = store.onSnapshotStateChange(
snapshotId,
(snap) => {
if (predicate(snap)) {
clearTimeout(timer);
if (typeof unsubscribe === 'function') unsubscribe();
resolve(snap);
}
},
options
);
});
}
it('fires the callback when the snapshot file changes', async () => {
const dir = tmpDir();
// Use a fast poll interval so the test does not depend on fs.watch
// delivering events on the host filesystem.
const store = new FileSessionStore(dir, {
snapshotWatchPollIntervalMs: 25,
});
const sessionId = globalThis.crypto.randomUUID();
const snapshotId = reserveSnapshotId();
await store.saveSnapshot(snapshotId, () => ({
...makeSnapshot(snapshotId, sessionId),
status: 'pending',
}));
const done = waitForSnapshot(
store,
snapshotId,
(snap) => snap.status === 'aborted'
);
// Simulate another process flipping the status to aborted.
await store.saveSnapshot(snapshotId, (current) => ({
...current!,
status: 'aborted',
}));
const snap = await done;
assert.strictEqual(snap.status, 'aborted');
});
it('surfaces the current state immediately on subscribe', async () => {
const dir = tmpDir();
const store = new FileSessionStore(dir, {
snapshotWatchPollIntervalMs: 25,
});
const sessionId = globalThis.crypto.randomUUID();
const snapshotId = reserveSnapshotId();
await store.saveSnapshot(snapshotId, () => ({
...makeSnapshot(snapshotId, sessionId),
status: 'completed',
}));
const snap = await waitForSnapshot(
store,
snapshotId,
(s) => s.status === 'completed'
);
assert.strictEqual(snap.snapshotId, snapshotId);
});
it('stops firing after unsubscribe', async () => {
const dir = tmpDir();
const store = new FileSessionStore(dir, {
snapshotWatchPollIntervalMs: 25,
});
const sessionId = globalThis.crypto.randomUUID();
const snapshotId = reserveSnapshotId();
await store.saveSnapshot(snapshotId, () =>
makeSnapshot(snapshotId, sessionId)
);
let calls = 0;
const unsubscribe = store.onSnapshotStateChange(snapshotId, () => {
calls++;
});
// Wait for the initial emit, then unsubscribe.
await new Promise((r) => setTimeout(r, 100));
if (typeof unsubscribe === 'function') unsubscribe();
const callsAfterUnsubscribe = calls;
// Further writes must not trigger the callback.
await store.saveSnapshot(snapshotId, (current) => ({
...current!,
status: 'aborted',
}));
await new Promise((r) => setTimeout(r, 200));
assert.strictEqual(calls, callsAfterUnsubscribe);
});
it('scopes the watch to the per-tenant prefix', async () => {
const dir = tmpDir();
const store = new FileSessionStore(dir, {
snapshotWatchPollIntervalMs: 25,
snapshotPathPrefix: (options) =>
(options?.context?.auth as any)?.uid ?? 'anon',
});
const sessionId = globalThis.crypto.randomUUID();
const snapshotId = reserveSnapshotId();
const aliceCtx = { context: { auth: { uid: 'alice' } } };
await store.saveSnapshot(
snapshotId,
() => ({
...makeSnapshot(snapshotId, sessionId),
status: 'pending',
}),
aliceCtx
);
const done = waitForSnapshot(
store,
snapshotId,
(snap) => snap.status === 'aborted',
aliceCtx
);
await store.saveSnapshot(
snapshotId,
(current) => ({ ...current!, status: 'aborted' }),
aliceCtx
);
const snap = await done;
assert.strictEqual(snap.status, 'aborted');
});
});
describe('FileSessionStore snapshotId validation', () => {
function tmpDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'genkit-sessions-'));
}
const traversalIds = [
'../escape',
'../../escape',
'foo/bar',
'foo\\bar',
'..',
'.',
];
for (const badId of traversalIds) {
it(`rejects getSnapshot for unsafe snapshotId ${JSON.stringify(badId)}`, async () => {
const store = new FileSessionStore(tmpDir());
await assert.rejects(
() => store.getSnapshot({ snapshotId: badId }),
/Invalid snapshotId/
);
});
it(`rejects saveSnapshot for unsafe snapshotId ${JSON.stringify(badId)}`, async () => {
const store = new FileSessionStore(tmpDir());
await assert.rejects(
() =>
store.saveSnapshot(badId, (current) => ({
...current,
snapshotId: badId,
createdAt: new Date().toISOString(),
state: { custom: {} },
status: 'completed' as const,
})),
/Invalid snapshotId/
);
});
}
it('does not write outside the store directory for a traversal id', async () => {
const dir = tmpDir();
const store = new FileSessionStore(dir);
await assert.rejects(
() =>
store.saveSnapshot('../../escaped', (current) => ({
...current,
snapshotId: '../../escaped',
createdAt: new Date().toISOString(),
state: { custom: {} },
status: 'completed' as const,
})),
/Invalid snapshotId/
);
// Nothing escaped to the parent of the store dir.
assert.ok(!fs.existsSync(path.join(dir, '..', 'escaped.json')));
});
it('accepts a plain basename snapshotId', async () => {
const store = new FileSessionStore(tmpDir());
const id = reserveSnapshotId();
const saved = await store.saveSnapshot(id, () => ({
snapshotId: id,
createdAt: new Date().toISOString(),
state: { custom: { ok: true } },
status: 'completed' as const,
}));
assert.strictEqual(saved, id);
const snap = await store.getSnapshot({ snapshotId: id });
assert.deepStrictEqual(snap?.state.custom, { ok: true });
});
});
});