fix(server): tool call test

This commit is contained in:
DarkSky
2026-07-05 03:59:45 +08:00
parent a7f824c42b
commit 65f4604094
2 changed files with 144 additions and 111 deletions
@@ -7,6 +7,7 @@ import type { TestFn } from 'ava';
import ava from 'ava';
import { nanoid } from 'nanoid';
import Sinon from 'sinon';
import { z } from 'zod';
import {
EventBus,
@@ -27,6 +28,7 @@ import {
WorkspaceModel,
WorkspaceRole,
} from '../../models';
import type { LlmToolCallbackRequest } from '../../native';
import { CopilotModule } from '../../plugins/copilot';
import { CopilotContextService } from '../../plugins/copilot/context';
import { CopilotContextResolver } from '../../plugins/copilot/context/resolver';
@@ -63,9 +65,14 @@ import { ImageResultHost } from '../../plugins/copilot/runtime/hosts/image-resul
import { ModelSelectionPolicy } from '../../plugins/copilot/runtime/model-selection-policy';
import { PromptRuntime } from '../../plugins/copilot/runtime/prompt-runtime';
import { getProviderRuntimeHost } from '../../plugins/copilot/runtime/provider-runtime-context';
import { executeToolCall } from '../../plugins/copilot/runtime/tool/bridge';
import { TurnOrchestrator } from '../../plugins/copilot/runtime/turn-orchestrator';
import { ChatSessionService } from '../../plugins/copilot/session';
import { CopilotStorage } from '../../plugins/copilot/storage';
import type {
CopilotToolExecuteOptions,
CopilotToolSet,
} from '../../plugins/copilot/tools';
import { CopilotTranscriptionService } from '../../plugins/copilot/transcript';
import { CopilotWorkspaceService } from '../../plugins/copilot/workspace';
import { PaymentModule } from '../../plugins/payment';
@@ -1272,6 +1279,143 @@ const wrapAsyncIter = async <T>(iter: AsyncIterable<T>) => {
return result;
};
test('tool bridge should validate zod tool args before execution', async t => {
const execute = Sinon.stub().resolves({ message: 'executed' });
const tools: CopilotToolSet = {
safeTool: {
inputSchema: z.object({
name: z.string(),
}),
execute,
},
};
const options: CopilotToolExecuteOptions = {};
const request: LlmToolCallbackRequest = {
callId: 'call-1',
name: 'safeTool',
args: { name: 123 },
rawArgumentsText: '{"name":123}',
};
const response = await executeToolCall(tools, request, options);
t.true(response.isError);
t.true(response.output instanceof Object);
t.regex((response.output as { message: string }).message, /Expected string/);
t.false(execute.called);
});
test('tool bridge should execute with parsed zod args and preserve callback response metadata', async t => {
const execute = Sinon.stub().resolves({ message: 'executed' });
const tools: CopilotToolSet = {
safeTool: {
inputSchema: z.object({
name: z.string().trim(),
}),
execute,
},
};
const options: CopilotToolExecuteOptions = {};
const request: LlmToolCallbackRequest = {
callId: 'call-2',
name: 'safeTool',
args: { name: ' AFFiNE ' },
rawArgumentsText: '{"name":" AFFiNE "}',
};
const response = await executeToolCall(tools, request, options);
t.false(response.isError ?? false);
t.deepEqual(response, {
callId: 'call-2',
name: 'safeTool',
args: { name: ' AFFiNE ' },
rawArgumentsText: '{"name":" AFFiNE "}',
argumentParseError: undefined,
output: { message: 'executed' },
});
t.deepEqual(execute.firstCall.args, [{ name: 'AFFiNE' }, options]);
});
test('tool bridge should reject malformed or unknown tool calls without executing tools', async t => {
const execute = Sinon.stub().resolves({ message: 'executed' });
const tools: CopilotToolSet = {
safeTool: {
inputSchema: z.record(z.unknown()),
execute,
},
};
const invalidJsonResponse = await executeToolCall(
tools,
{
callId: 'call-3',
name: 'safeTool',
args: {},
rawArgumentsText: '{"unterminated"',
argumentParseError: 'Unexpected end of JSON input',
},
{}
);
const missingToolResponse = await executeToolCall(
tools,
{
callId: 'call-4',
name: 'missingTool',
args: {},
rawArgumentsText: '{}',
},
{}
);
t.deepEqual(invalidJsonResponse, {
callId: 'call-3',
name: 'safeTool',
args: {},
rawArgumentsText: '{"unterminated"',
argumentParseError: 'Unexpected end of JSON input',
isError: true,
output: {
message: 'Invalid tool arguments JSON',
rawArguments: '{"unterminated"',
error: 'Unexpected end of JSON input',
},
});
t.deepEqual(missingToolResponse, {
callId: 'call-4',
name: 'missingTool',
args: {},
rawArgumentsText: '{}',
argumentParseError: undefined,
isError: true,
output: { message: 'Tool not found: missingTool' },
});
t.false(execute.called);
});
test('tool bridge should not mutate global object prototype for adversarial args', async t => {
const execute = Sinon.stub().resolves({ message: 'executed' });
const tools: CopilotToolSet = {
safeTool: {
inputSchema: z.record(z.unknown()),
execute,
},
};
const request: LlmToolCallbackRequest = {
callId: 'call-5',
name: 'safeTool',
args: JSON.parse('{"__proto__":{"polluted":"yes"}}'),
rawArgumentsText: '{"__proto__":{"polluted":"yes"}}',
};
const response = await executeToolCall(tools, request, {});
t.true(Object.prototype.hasOwnProperty.call(request.args, '__proto__'));
t.false(response.isError ?? false);
t.is((Object.prototype as Record<string, unknown>).polluted, undefined);
t.deepEqual(execute.firstCall.args[0], {});
});
test('action stream should expose successful text action result as message', t => {
t.deepEqual(
projectActionEventToChatEvent('message-1', {