/**
* 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 { action, z } from '@genkit-ai/core';
import { initNodeFeatures } from '@genkit-ai/core/node';
import { Registry } from '@genkit-ai/core/registry';
import * as assert from 'assert';
import { afterEach, describe, it } from 'node:test';
import {
defineInterrupt,
defineTool,
isDynamicTool,
tool,
} from '../src/tool.js';
initNodeFeatures();
describe('defineInterrupt', () => {
let registry = new Registry();
registry.apiStability = 'beta';
afterEach(() => {
registry = new Registry();
registry.apiStability = 'beta';
});
function expectInterrupt(fn: () => any, metadata?: Record<string, any>) {
return assert.rejects(fn, { name: 'ToolInterruptError', metadata });
}
it('should throw a simple interrupt with no metadata', async () => {
const simple = defineInterrupt(registry, {
name: 'simple',
description: 'simple interrupt',
});
await expectInterrupt(async () => {
await simple({});
});
});
it('should throw a simple interrupt with fixed metadata', async () => {
const simple = defineInterrupt(registry, {
name: 'simple',
description: 'simple interrupt',
requestMetadata: { foo: 'bar' },
});
await expectInterrupt(
async () => {
await simple({});
},
{ foo: 'bar' }
);
});
it('should throw a simple interrupt with function-returned metadata', async () => {
const simple = defineInterrupt(registry, {
name: 'simple',
description: 'simple interrupt',
inputSchema: z.string(),
requestMetadata: (foo) => ({ foo }),
});
await expectInterrupt(
async () => {
await simple('bar');
},
{ foo: 'bar' }
);
});
it('should throw a simple interrupt with async function-returned metadata', async () => {
const simple = defineInterrupt(registry, {
name: 'simple',
description: 'simple interrupt',
inputSchema: z.string(),
requestMetadata: async (foo) => ({ foo }),
});
await expectInterrupt(
async () => {
await simple('bar');
},
{ foo: 'bar' }
);
});
it('should register the reply schema / json schema as the output schema of the tool', () => {
const ResponseSchema = z.object({ foo: z.string() });
const simple = defineInterrupt(registry, {
name: 'simple',
description: 'simple',
outputSchema: ResponseSchema,
});
assert.equal(simple.__action.outputSchema, ResponseSchema);
const simple2 = defineInterrupt(registry, {
name: 'simple2',
description: 'simple2',
outputJsonSchema: { type: 'string' },
});
assert.deepStrictEqual(simple2.__action.outputJsonSchema, {
type: 'string',
});
});
describe('multipart tools', () => {
it('should define a multipart tool', async () => {
const t = defineTool(
registry,
{ name: 'test', description: 'test', multipart: true },
async () => {
return {
output: 'main output',
content: [{ text: 'part 1' }],
};
}
);
assert.equal(t.__action.metadata.type, 'tool.v2');
assert.equal(t.__action.actionType, 'tool.v2');
const result = await t({});
assert.deepStrictEqual(result, {
output: 'main output',
content: [{ text: 'part 1' }],
});
});
it('should handle fallback output', async () => {
const t = defineTool(
registry,
{ name: 'test', description: 'test', multipart: true },
async () => {
return {
content: [{ text: 'part 1' }],
};
}
);
const result = await t({});
assert.deepStrictEqual(result, {
content: [{ text: 'part 1' }],
});
});
});
});
describe('isDynamicTool', () => {
let registry = new Registry();
registry.apiStability = 'beta';
afterEach(() => {
registry = new Registry();
registry.apiStability = 'beta';
});
it('should return true for a dynamic tool', () => {
const dynamic = tool({ name: 'dynamic', description: 'test' });
assert.strictEqual(isDynamicTool(dynamic), true);
});
it('should remain dynamic after registration', () => {
const dynamic = tool({ name: 'dynamic', description: 'test' });
assert.strictEqual(isDynamicTool(dynamic), true);
registry.registerAction('tool', dynamic);
assert.strictEqual(isDynamicTool(dynamic), true);
});
it('should return false for a registered tool', () => {
const regular = defineTool(
registry,
{ name: 'regular', description: 'test' },
async () => {}
);
assert.strictEqual(isDynamicTool(regular), false);
});
it('should return false for a non-tool action', () => {
const regularAction = action(
{ actionType: 'util', name: 'regularAction', description: 'test' },
async () => {}
);
assert.strictEqual(isDynamicTool(regularAction), false);
});
it('should return false for a non-action', () => {
assert.strictEqual(isDynamicTool({}), false);
assert.strictEqual(isDynamicTool('tool'), false);
assert.strictEqual(isDynamicTool(123), false);
});
});
describe('defineTool', () => {
let registry = new Registry();
registry.apiStability = 'beta';
afterEach(() => {
registry = new Registry();
registry.apiStability = 'beta';
});
describe('.respond()', () => {
it('constructs a ToolResponsePart', () => {
const t = defineTool(
registry,
{ name: 'test', description: 'test' },
async () => {}
);
assert.deepStrictEqual(
t.respond({ toolRequest: { name: 'test', input: {} } }, 'output'),
{
toolResponse: {
name: 'test',
output: 'output',
},
metadata: {
interruptResponse: true,
},
}
);
});
it('includes metadata', () => {
const t = defineTool(
registry,
{ name: 'test', description: 'test' },
async () => {}
);
assert.deepStrictEqual(
t.respond({ toolRequest: { name: 'test', input: {} } }, 'output', {
metadata: { extra: 'data' },
}),
{
toolResponse: {
name: 'test',
output: 'output',
},
metadata: {
interruptResponse: { extra: 'data' },
},
}
);
});
it('validates schema', () => {
const t = defineTool(
registry,
{ name: 'test', description: 'test', outputSchema: z.number() },
async (input, { interrupt }) => interrupt()
);
assert.throws(
() => {
t.respond(
{ toolRequest: { name: 'test', input: {} } },
'not_a_number' as any
);
},
{ name: 'GenkitError', status: 'INVALID_ARGUMENT' }
);
assert.deepStrictEqual(
t.respond({ toolRequest: { name: 'test', input: {} } }, 55),
{
toolResponse: {
name: 'test',
output: 55,
},
metadata: {
interruptResponse: true,
},
}
);
});
});
describe('.restart()', () => {
it('constructs a ToolRequestPart', () => {
const t = defineTool(
registry,
{ name: 'test', description: 'test' },
async () => {}
);
assert.deepStrictEqual(
t.restart({ toolRequest: { name: 'test', input: {} } }),
{
toolRequest: {
name: 'test',
input: {},
},
metadata: {
resumed: true,
},
}
);
});
it('includes metadata', () => {
const t = defineTool(
registry,
{ name: 'test', description: 'test' },
async () => {}
);
assert.deepStrictEqual(
t.restart(
{ toolRequest: { name: 'test', input: {} } },
{ extra: 'data' }
),
{
toolRequest: {
name: 'test',
input: {},
},
metadata: {
resumed: { extra: 'data' },
},
}
);
});
it('validates schema', () => {
const t = defineTool(
registry,
{ name: 'test', description: 'test', inputSchema: z.number() },
async (input, { interrupt }) => interrupt()
);
assert.throws(
() => {
t.restart({ toolRequest: { name: 'test', input: {} } }, undefined, {
replaceInput: 'not_a_number' as any,
});
},
{ name: 'GenkitError', status: 'INVALID_ARGUMENT' }
);
assert.deepStrictEqual(
t.restart({ toolRequest: { name: 'test', input: {} } }, undefined, {
replaceInput: 55,
}),
{
toolRequest: {
name: 'test',
input: 55,
},
metadata: {
resumed: true,
replacedInput: {},
},
}
);
});
});
it('should register a v1 tool as v2 as well', async () => {
defineTool(registry, { name: 'test', description: 'test' }, async () => {});
assert.ok(await registry.lookupAction('/tool/test'));
assert.ok(await registry.lookupAction('/tool.v2/test'));
});
it('should only register a multipart tool as v2', async () => {
defineTool(
registry,
{ name: 'test', description: 'test', multipart: true },
async () => {}
);
assert.ok(await registry.lookupAction('/tool.v2/test'));
assert.equal(await registry.lookupAction('/tool/test'), undefined);
});
it('should wrap v1 tool output when called as v2', async () => {
defineTool(
registry,
{ name: 'test', description: 'test' },
async () => 'foo'
);
const action = await registry.lookupAction('/tool.v2/test');
assert.ok(action);
const result = await action!({});
assert.deepStrictEqual(result, { output: 'foo' });
});
});