File size: 7,857 Bytes
fae625a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MessageToolUpdateType, MessageUpdateType } from "$lib/types/MessageUpdate";
import { ToolResultStatus } from "$lib/types/Tool";
import { parseToolArguments } from "./toolArgs";
import type { NormalizedToolCall } from "./toolInvocation";
import type { McpToolTextResponse } from "$lib/server/mcp/httpClient";
import type { ChatCompletionToolMessageParam } from "openai/resources/chat/completions";

const mcpMock = vi.hoisted(() => ({
	callMcpTool: vi.fn(),
}));

vi.mock("$lib/server/mcp/httpClient", () => ({
	callMcpTool: mcpMock.callMcpTool,
	getMcpToolTimeoutMs: () => 1_000,
}));

vi.mock("$lib/server/mcp/clientPool", () => ({
	getClient: vi.fn(async () => ({})),
}));

vi.mock("../../logger", () => ({
	logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));

const { executeToolCalls, isValidJsonObject } = await import("./toolInvocation");

const SERVERS = [{ name: "hf", url: "https://example.test/mcp" }];
const MAPPING = { do_thing: { fnName: "do_thing", server: "hf", tool: "do_thing" } };
const CALL: NormalizedToolCall = { id: "call_1", name: "do_thing", arguments: '{"a":1}' };

const toPrimitive = (value: unknown) =>
	typeof value === "string" || typeof value === "number" || typeof value === "boolean"
		? value
		: undefined;

const processToolOutput = (text: string) => ({ annotated: text, sources: [] });

async function drain(calls: NormalizedToolCall[]) {
	const events = [];
	for await (const event of executeToolCalls({
		calls,
		mapping: MAPPING,
		servers: SERVERS,
		parseArgs: parseToolArguments,
		toPrimitive,
		processToolOutput,
	})) {
		events.push(event);
	}
	return events;
}

type Events = Awaited<ReturnType<typeof drain>>;

function summaryOf(events: Events) {
	const complete = events.find((e) => e.type === "complete");
	if (complete?.type !== "complete") throw new Error("no completion event");
	return complete.summary;
}

function toolMessagesOf(events: Events) {
	return summaryOf(events).toolMessages.filter(
		(message): message is ChatCompletionToolMessageParam => message.role === "tool"
	);
}

function toolUpdatesOf(events: Events) {
	return events.flatMap((e) =>
		e.type === "update" && e.update.type === MessageUpdateType.Tool ? [e.update] : []
	);
}

function mcpResult(overrides: Partial<McpToolTextResponse>): McpToolTextResponse {
	return { text: "", isError: false, ...overrides };
}

beforeEach(() => {
	mcpMock.callMcpTool.mockReset();
	mcpMock.callMcpTool.mockResolvedValue(mcpResult({ text: "ok" }));
});

describe("executeToolCalls", () => {
	it("reports a successful call as a success", async () => {
		mcpMock.callMcpTool.mockResolvedValue(mcpResult({ text: "all good" }));

		const events = await drain([CALL]);

		expect(toolMessagesOf(events)).toEqual([
			{ role: "tool", tool_call_id: "call_1", content: "all good" },
		]);
		expect(summaryOf(events).toolRuns).toHaveLength(1);
		const result = toolUpdatesOf(events).find((u) => u.subtype === MessageToolUpdateType.Result);
		expect(result).toBeDefined();
		if (result?.subtype === MessageToolUpdateType.Result) {
			expect(result.result.status).toBe(ToolResultStatus.Success);
		}
	});

	// MCP reports tool failures as a normal result with `isError: true` rather than by
	// throwing, so this path never reaches the catch. Before the fix it was reported to
	// both the user and the model as a success.
	it("reports an isError result as a failure and tells the model", async () => {
		mcpMock.callMcpTool.mockResolvedValue(
			mcpResult({ text: "repo not found: acme/missing", isError: true })
		);

		const events = await drain([CALL]);

		expect(toolMessagesOf(events)).toEqual([
			{ role: "tool", tool_call_id: "call_1", content: "Error: repo not found: acme/missing" },
		]);
		// A failed call produced no output, so it must not appear as a completed run.
		expect(summaryOf(events).toolRuns).toHaveLength(0);

		const updates = toolUpdatesOf(events);
		expect(updates.some((u) => u.subtype === MessageToolUpdateType.Result)).toBe(false);
		const error = updates.find((u) => u.subtype === MessageToolUpdateType.Error);
		expect(error).toBeDefined();
		if (error?.subtype === MessageToolUpdateType.Error) {
			expect(error.message).toBe("repo not found: acme/missing");
		}
	});

	it("falls back to a placeholder when an isError result carries no text", async () => {
		mcpMock.callMcpTool.mockResolvedValue(mcpResult({ text: "   ", isError: true }));

		const events = await drain([CALL]);

		expect(toolMessagesOf(events)[0]).toEqual({
			role: "tool",
			tool_call_id: "call_1",
			content: "Error: The tool reported an error with no message.",
		});
	});

	it("still reports a thrown transport error as a failure", async () => {
		mcpMock.callMcpTool.mockRejectedValue(new Error("connection refused"));

		const events = await drain([CALL]);

		expect(toolMessagesOf(events)[0]).toEqual({
			role: "tool",
			tool_call_id: "call_1",
			content: "Error: connection refused",
		});
	});
});

describe("executeToolCalls argument handling", () => {
	// Regression: undecodable arguments used to be coerced to `{}` and dispatched.
	it("does not dispatch a call whose arguments are truncated JSON", async () => {
		const events = await drain([
			{ id: "call_1", name: "do_thing", arguments: '{"path":"train.py","content":"import tor' },
		]);

		expect(mcpMock.callMcpTool).not.toHaveBeenCalled();

		expect(summaryOf(events).toolRuns).toHaveLength(0);
		const toolMessages = toolMessagesOf(events);
		expect(toolMessages).toHaveLength(1);
		expect(toolMessages[0].tool_call_id).toBe("call_1");
		expect(String(toolMessages[0].content)).toContain("Invalid tool arguments");

		const error = toolUpdatesOf(events).find((u) => u.subtype === MessageToolUpdateType.Error);
		expect(error).toBeDefined();
	});

	it("still dispatches a call that takes no arguments", async () => {
		const events = await drain([{ id: "call_1", name: "do_thing", arguments: "" }]);

		expect(mcpMock.callMcpTool).toHaveBeenCalledTimes(1);
		expect(mcpMock.callMcpTool.mock.calls[0][2]).toEqual({});
		expect(summaryOf(events).toolRuns).toHaveLength(1);
	});

	it("dispatches valid calls in a batch even when a sibling call is malformed", async () => {
		const events = await drain([
			{ id: "call_1", name: "do_thing", arguments: '{"broken":' },
			{ id: "call_2", name: "do_thing", arguments: '{"ok":true}' },
		]);

		expect(mcpMock.callMcpTool).toHaveBeenCalledTimes(1);

		// Collated in call order, so the model can match each outcome to its call.
		const toolMessages = toolMessagesOf(events);
		expect(toolMessages.map((m) => m.tool_call_id)).toEqual(["call_1", "call_2"]);
		expect(String(toolMessages[0].content)).toContain("Invalid tool arguments");
		expect(toolMessages[1].content).toBe("ok");
	});
});

describe("isValidJsonObject", () => {
	it("accepts a well-formed JSON object", () => {
		expect(isValidJsonObject('{"city":"Paris"}')).toBe(true);
		expect(isValidJsonObject("{}")).toBe(true);
	});

	it("rejects malformed or truncated JSON", () => {
		// The exact failure mode this guards: a model streams a truncated
		// arguments string, which must never be persisted as argumentsRaw and
		// later replayed as an invalid historical tool_calls.function.arguments.
		expect(isValidJsonObject('{"city":"Pari')).toBe(false);
		expect(isValidJsonObject("")).toBe(false);
		expect(isValidJsonObject("not json at all")).toBe(false);
	});

	it("rejects valid JSON that isn't an object", () => {
		// Tool-call arguments must be an object; arrays/primitives/null are
		// syntactically valid JSON but never a valid arguments shape.
		expect(isValidJsonObject("[1,2,3]")).toBe(false);
		expect(isValidJsonObject("null")).toBe(false);
		expect(isValidJsonObject('"a string"')).toBe(false);
		expect(isValidJsonObject("42")).toBe(false);
	});
});