@genkit-ai/core
Version:
Genkit AI framework core libraries.
304 lines (269 loc) • 8.23 kB
text/typescript
/**
* Copyright 2025 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as assert from 'assert';
import { beforeEach, describe, it } from 'node:test';
import { z } from 'zod';
import { defineBidiAction } from '../src/action.js';
import { initNodeFeatures } from '../src/node.js';
import { Registry } from '../src/registry.js';
initNodeFeatures();
describe('bidi action', () => {
var registry: Registry;
beforeEach(() => {
registry = new Registry();
});
it('streamBidi ergonomic (push)', async () => {
const act = defineBidiAction(
registry,
{
name: 'chat',
actionType: 'custom',
outputSchema: z.string(),
inputSchema: z.string(), // Option 1: messages are strings
},
async function* ({ inputStream }) {
for await (const chunk of inputStream) {
yield `echo ${chunk}`;
}
return 'done';
}
);
const session = act.streamBidi();
session.send('1');
session.send('2');
session.close();
const chunks: string[] = [];
for await (const chunk of session.stream) {
chunks.push(chunk);
}
assert.deepStrictEqual(chunks, ['echo 1', 'echo 2']);
assert.strictEqual(await session.output, 'done');
});
it('streamBidi pull (generator)', async () => {
const act = defineBidiAction(
registry,
{
name: 'chat',
actionType: 'custom',
outputSchema: z.string(),
inputSchema: z.string(),
},
async function* ({ inputStream }) {
for await (const chunk of inputStream) {
yield `echo ${chunk}`;
}
return 'done';
}
);
async function* inputGen() {
yield '1';
yield '2';
}
const session = act.streamBidi(undefined, { inputStream: inputGen() }); // Pass stream in options!
const chunks: string[] = [];
for await (const chunk of session.stream) {
chunks.push(chunk);
}
assert.deepStrictEqual(chunks, ['echo 1', 'echo 2']);
assert.strictEqual(await session.output, 'done');
});
it('classic run works on bidi action', async () => {
const act = defineBidiAction(
registry,
{
name: 'chat',
actionType: 'custom',
outputSchema: z.string(),
inputSchema: z.string(),
},
async function* ({ inputStream }) {
const inputs: string[] = [];
for await (const chunk of inputStream) {
inputs.push(chunk);
}
return `done: ${inputs.join(', ')}`;
}
);
const result = await act.run('1'); // Pass single message as input!
assert.strictEqual(result.result, 'done: 1');
});
it('classic run works on bidi action with streaming', async () => {
const act = defineBidiAction(
registry,
{
name: 'chat',
actionType: 'custom',
outputSchema: z.string(),
inputSchema: z.string(),
},
async function* ({ inputStream }) {
for await (const chunk of inputStream) {
yield `echo ${chunk}`;
}
return 'done';
}
);
const chunks: string[] = [];
const result = await act.run('1', {
onChunk: (c) => chunks.push(c),
});
assert.deepStrictEqual(chunks, ['echo 1']);
assert.strictEqual(result.result, 'done');
});
it('bidi action receives init data', async () => {
const act = defineBidiAction(
registry,
{
name: 'chatWithInit',
actionType: 'custom',
outputSchema: z.string(),
inputSchema: z.string(),
initSchema: z.object({ prefix: z.string() }),
},
async function* ({ inputStream, init }) {
const prefix = init?.prefix || '';
for await (const chunk of inputStream) {
yield `${prefix}${chunk}`;
}
return 'done';
}
);
const session = act.streamBidi({ prefix: '>> ' }); // Pass init as first argument!
session.send('1');
session.send('2');
session.close();
const chunks: string[] = [];
for await (const chunk of session.stream) {
chunks.push(chunk);
}
assert.deepStrictEqual(chunks, ['>> 1', '>> 2']);
assert.strictEqual(await session.output, 'done');
});
it('classic run works on bidi action with init', async () => {
const act = defineBidiAction(
registry,
{
name: 'chatWithInitRun',
actionType: 'custom',
outputSchema: z.string(),
inputSchema: z.string(),
initSchema: z.object({ prefix: z.string() }),
},
async function* ({ inputStream, init }) {
const prefix = init?.prefix || '';
const inputs: string[] = [];
for await (const chunk of inputStream) {
inputs.push(chunk);
}
return `${prefix}${inputs.join(', ')}`;
}
);
const result = await act.run('1', { init: { prefix: '>> ' } });
assert.strictEqual(result.result, '>> 1');
});
it('classic stream works on bidi action with init', async () => {
const act = defineBidiAction(
registry,
{
name: 'chatWithInitStream',
actionType: 'custom',
outputSchema: z.string(),
inputSchema: z.string(),
initSchema: z.object({ prefix: z.string() }),
},
async function* ({ inputStream, init }) {
const prefix = init?.prefix || '';
for await (const chunk of inputStream) {
yield `${prefix}${chunk}`;
}
return 'done';
}
);
const session = act.stream('1', { init: { prefix: '>> ' } });
const chunks: string[] = [];
for await (const chunk of session.stream) {
chunks.push(chunk);
}
assert.deepStrictEqual(chunks, ['>> 1']);
assert.strictEqual(await session.output, 'done');
});
it('bidi action generator is closed on sendChunk error', async () => {
let generatorClosed = false;
const act = defineBidiAction(
registry,
{
name: 'chatCleanup',
actionType: 'custom',
outputSchema: z.string(),
inputSchema: z.string(),
},
async function* ({ inputStream }) {
try {
for await (const chunk of inputStream) {
yield `echo ${chunk}`;
}
} finally {
generatorClosed = true;
}
return 'done';
}
);
try {
await act.run('1', {
onChunk: (c) => {
throw new Error('abort');
},
});
assert.fail('Expected error to be thrown');
} catch (e: any) {
assert.strictEqual(e.message, 'abort');
}
assert.strictEqual(generatorClosed, true, 'Generator should be closed');
});
it('bidi action generator is closed on early break by consumer', async () => {
let generatorClosed = false;
const act = defineBidiAction(
registry,
{
name: 'chatCleanupBreak',
actionType: 'custom',
outputSchema: z.string(),
inputSchema: z.string(),
},
async function* ({ inputStream }) {
try {
for await (const chunk of inputStream) {
yield `echo ${chunk}`;
}
} finally {
generatorClosed = true;
}
return 'done';
}
);
const session = act.streamBidi();
session.send('1');
session.send('2');
// Read only one chunk and break
for await (const chunk of session.stream) {
assert.strictEqual(chunk, 'echo 1');
break; // Stop reading early
}
// Give the stream some time to propagate the close.
await new Promise((resolve) => setTimeout(resolve, 10));
assert.strictEqual(generatorClosed, true, 'Generator should be closed');
});
});