zeitlich 0.2.14 → 0.2.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -12
- package/dist/adapters/sandbox/daytona/index.cjs +52 -23
- package/dist/adapters/sandbox/daytona/index.cjs.map +1 -1
- package/dist/adapters/sandbox/daytona/index.d.cts +10 -2
- package/dist/adapters/sandbox/daytona/index.d.ts +10 -2
- package/dist/adapters/sandbox/daytona/index.js +52 -23
- package/dist/adapters/sandbox/daytona/index.js.map +1 -1
- package/dist/adapters/sandbox/inmemory/index.cjs +21 -16
- package/dist/adapters/sandbox/inmemory/index.cjs.map +1 -1
- package/dist/adapters/sandbox/inmemory/index.d.cts +1 -1
- package/dist/adapters/sandbox/inmemory/index.d.ts +1 -1
- package/dist/adapters/sandbox/inmemory/index.js +21 -16
- package/dist/adapters/sandbox/inmemory/index.js.map +1 -1
- package/dist/adapters/sandbox/virtual/index.cjs +38 -38
- package/dist/adapters/sandbox/virtual/index.cjs.map +1 -1
- package/dist/adapters/sandbox/virtual/index.d.cts +6 -6
- package/dist/adapters/sandbox/virtual/index.d.ts +6 -6
- package/dist/adapters/sandbox/virtual/index.js +37 -37
- package/dist/adapters/sandbox/virtual/index.js.map +1 -1
- package/dist/adapters/thread/google-genai/index.cjs +22 -0
- package/dist/adapters/thread/google-genai/index.cjs.map +1 -1
- package/dist/adapters/thread/google-genai/index.d.cts +3 -3
- package/dist/adapters/thread/google-genai/index.d.ts +3 -3
- package/dist/adapters/thread/google-genai/index.js +22 -0
- package/dist/adapters/thread/google-genai/index.js.map +1 -1
- package/dist/adapters/thread/langchain/index.cjs +22 -0
- package/dist/adapters/thread/langchain/index.cjs.map +1 -1
- package/dist/adapters/thread/langchain/index.d.cts +3 -3
- package/dist/adapters/thread/langchain/index.d.ts +3 -3
- package/dist/adapters/thread/langchain/index.js +22 -0
- package/dist/adapters/thread/langchain/index.js.map +1 -1
- package/dist/index.cjs +38 -11
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +6 -6
- package/dist/index.d.ts +6 -6
- package/dist/index.js +38 -11
- package/dist/index.js.map +1 -1
- package/dist/{types-B9ljZewB.d.cts → types-35POpVfa.d.cts} +6 -0
- package/dist/{types-B9ljZewB.d.ts → types-35POpVfa.d.ts} +6 -0
- package/dist/{types-CDubRtad.d.cts → types-BMRzfELQ.d.cts} +2 -0
- package/dist/{types-CDubRtad.d.ts → types-BMRzfELQ.d.ts} +2 -0
- package/dist/{types-CwwgQ_9H.d.ts → types-BSOte_8s.d.ts} +6 -2
- package/dist/{types-BVP87m_W.d.cts → types-DCi2qXjN.d.cts} +6 -2
- package/dist/{types-GpMU4b0w.d.cts → types-Drli9aCK.d.cts} +3 -1
- package/dist/{types-B4C9txdq.d.ts → types-XPtivmSJ.d.ts} +3 -1
- package/dist/workflow.cjs +23 -11
- package/dist/workflow.cjs.map +1 -1
- package/dist/workflow.d.cts +6 -6
- package/dist/workflow.d.ts +6 -6
- package/dist/workflow.js +23 -11
- package/dist/workflow.js.map +1 -1
- package/package.json +7 -3
- package/src/adapters/sandbox/daytona/filesystem.ts +43 -19
- package/src/adapters/sandbox/daytona/index.ts +16 -3
- package/src/adapters/sandbox/daytona/types.ts +4 -0
- package/src/adapters/sandbox/inmemory/index.ts +22 -16
- package/src/adapters/sandbox/virtual/filesystem.ts +29 -31
- package/src/adapters/sandbox/virtual/index.ts +5 -3
- package/src/adapters/sandbox/virtual/provider.ts +5 -2
- package/src/adapters/sandbox/virtual/types.ts +3 -0
- package/src/adapters/sandbox/virtual/with-virtual-sandbox.ts +4 -3
- package/src/adapters/thread/google-genai/activities.ts +11 -0
- package/src/adapters/thread/langchain/activities.ts +11 -0
- package/src/lib/sandbox/tree.integration.test.ts +153 -0
- package/src/lib/sandbox/types.ts +2 -0
- package/src/lib/session/session-edge-cases.integration.test.ts +962 -0
- package/src/lib/session/session.integration.test.ts +852 -0
- package/src/lib/session/session.ts +11 -5
- package/src/lib/session/types.ts +2 -0
- package/src/lib/skills/skills.integration.test.ts +308 -0
- package/src/lib/state/manager.integration.test.ts +342 -0
- package/src/lib/subagent/register.ts +22 -7
- package/src/lib/subagent/subagent.integration.test.ts +467 -0
- package/src/lib/thread/id.test.ts +50 -0
- package/src/lib/thread/manager.ts +20 -1
- package/src/lib/thread/types.ts +6 -0
- package/src/lib/tool-router/auto-append-sandbox.integration.test.ts +344 -0
- package/src/lib/tool-router/router-edge-cases.integration.test.ts +623 -0
- package/src/lib/tool-router/router.integration.test.ts +699 -0
- package/src/lib/types.test.ts +29 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
vi.mock("@temporalio/workflow", () => {
|
|
5
|
+
class MockApplicationFailure extends Error {
|
|
6
|
+
nonRetryable?: boolean;
|
|
7
|
+
static create({
|
|
8
|
+
message,
|
|
9
|
+
nonRetryable,
|
|
10
|
+
}: {
|
|
11
|
+
message: string;
|
|
12
|
+
nonRetryable?: boolean;
|
|
13
|
+
}) {
|
|
14
|
+
const err = new MockApplicationFailure(message);
|
|
15
|
+
err.nonRetryable = nonRetryable;
|
|
16
|
+
return err;
|
|
17
|
+
}
|
|
18
|
+
static fromError(
|
|
19
|
+
error: unknown,
|
|
20
|
+
options?: { nonRetryable?: boolean },
|
|
21
|
+
) {
|
|
22
|
+
const src = error instanceof Error ? error : new Error(String(error));
|
|
23
|
+
const err = new MockApplicationFailure(src.message);
|
|
24
|
+
err.nonRetryable = options?.nonRetryable;
|
|
25
|
+
return err;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { ApplicationFailure: MockApplicationFailure };
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
import { createToolRouter, defineTool, hasNoOtherToolCalls } from "./router";
|
|
32
|
+
import type {
|
|
33
|
+
ToolMap,
|
|
34
|
+
ToolHandlerResponse,
|
|
35
|
+
AppendToolResultFn,
|
|
36
|
+
} from "./types";
|
|
37
|
+
import type { ToolResultConfig } from "../types";
|
|
38
|
+
|
|
39
|
+
function createAppendSpy() {
|
|
40
|
+
const calls: ToolResultConfig[] = [];
|
|
41
|
+
const fn: AppendToolResultFn = async (config) => {
|
|
42
|
+
calls.push(config);
|
|
43
|
+
};
|
|
44
|
+
return { fn, calls };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function at<T>(arr: T[], index: number): T {
|
|
48
|
+
const val = arr[index];
|
|
49
|
+
if (val === undefined) throw new Error(`Index ${index} out of bounds`);
|
|
50
|
+
return val;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("createToolRouter edge cases", () => {
|
|
54
|
+
let appendSpy: ReturnType<typeof createAppendSpy>;
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
appendSpy = createAppendSpy();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// --- Empty tools ---
|
|
61
|
+
|
|
62
|
+
it("hasTools returns false when no tools are registered", () => {
|
|
63
|
+
const router = createToolRouter({
|
|
64
|
+
tools: {} as ToolMap,
|
|
65
|
+
threadId: "t-1",
|
|
66
|
+
appendToolResult: appendSpy.fn,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(router.hasTools()).toBe(false);
|
|
70
|
+
expect(router.getToolNames()).toEqual([]);
|
|
71
|
+
expect(router.getToolDefinitions()).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("hasTools returns false when all tools are disabled", () => {
|
|
75
|
+
const disabledTool = defineTool({
|
|
76
|
+
name: "Off" as const,
|
|
77
|
+
description: "disabled",
|
|
78
|
+
schema: z.object({}),
|
|
79
|
+
handler: async () => ({ toolResponse: "nope", data: null }),
|
|
80
|
+
enabled: false,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const router = createToolRouter({
|
|
84
|
+
tools: { Off: disabledTool } as const,
|
|
85
|
+
threadId: "t-1",
|
|
86
|
+
appendToolResult: appendSpy.fn,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(router.hasTools()).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// --- processToolCalls with empty array ---
|
|
93
|
+
|
|
94
|
+
it("returns empty results for empty toolCalls array", async () => {
|
|
95
|
+
const echoTool = defineTool({
|
|
96
|
+
name: "Echo" as const,
|
|
97
|
+
description: "echo",
|
|
98
|
+
schema: z.object({ text: z.string() }),
|
|
99
|
+
handler: async (args: { text: string }) => ({
|
|
100
|
+
toolResponse: args.text,
|
|
101
|
+
data: { echoed: args.text },
|
|
102
|
+
}),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const router = createToolRouter({
|
|
106
|
+
tools: { Echo: echoTool } as const,
|
|
107
|
+
threadId: "t-1",
|
|
108
|
+
appendToolResult: appendSpy.fn,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const results = await router.processToolCalls([]);
|
|
112
|
+
expect(results).toEqual([]);
|
|
113
|
+
expect(appendSpy.calls).toHaveLength(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// --- Both global and per-tool pre-hooks run in order ---
|
|
117
|
+
|
|
118
|
+
it("global pre-hook runs before per-tool pre-hook", async () => {
|
|
119
|
+
const order: string[] = [];
|
|
120
|
+
|
|
121
|
+
const hookedTool = defineTool({
|
|
122
|
+
name: "Hooked" as const,
|
|
123
|
+
description: "hooked tool",
|
|
124
|
+
schema: z.object({}),
|
|
125
|
+
handler: async () => {
|
|
126
|
+
order.push("handler");
|
|
127
|
+
return { toolResponse: "ok", data: null };
|
|
128
|
+
},
|
|
129
|
+
hooks: {
|
|
130
|
+
onPreToolUse: async () => {
|
|
131
|
+
order.push("tool-pre");
|
|
132
|
+
return {};
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const router = createToolRouter({
|
|
138
|
+
tools: { Hooked: hookedTool } as const,
|
|
139
|
+
threadId: "t-1",
|
|
140
|
+
appendToolResult: appendSpy.fn,
|
|
141
|
+
hooks: {
|
|
142
|
+
onPreToolUse: async () => {
|
|
143
|
+
order.push("global-pre");
|
|
144
|
+
return {};
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const parsed = router.parseToolCall({ id: "tc-1", name: "Hooked", args: {} });
|
|
150
|
+
await router.processToolCalls([parsed], { turn: 1 });
|
|
151
|
+
|
|
152
|
+
expect(order).toEqual(["global-pre", "tool-pre", "handler"]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// --- Global pre-hook skip prevents per-tool pre-hook from running ---
|
|
156
|
+
|
|
157
|
+
it("global pre-hook skip prevents per-tool pre-hook and handler", async () => {
|
|
158
|
+
const order: string[] = [];
|
|
159
|
+
|
|
160
|
+
const hookedTool = defineTool({
|
|
161
|
+
name: "Hooked" as const,
|
|
162
|
+
description: "hooked tool",
|
|
163
|
+
schema: z.object({}),
|
|
164
|
+
handler: async () => {
|
|
165
|
+
order.push("handler");
|
|
166
|
+
return { toolResponse: "ok", data: null };
|
|
167
|
+
},
|
|
168
|
+
hooks: {
|
|
169
|
+
onPreToolUse: async () => {
|
|
170
|
+
order.push("tool-pre");
|
|
171
|
+
return {};
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const router = createToolRouter({
|
|
177
|
+
tools: { Hooked: hookedTool } as const,
|
|
178
|
+
threadId: "t-1",
|
|
179
|
+
appendToolResult: appendSpy.fn,
|
|
180
|
+
hooks: {
|
|
181
|
+
onPreToolUse: async () => {
|
|
182
|
+
order.push("global-pre-skip");
|
|
183
|
+
return { skip: true };
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const parsed = router.parseToolCall({ id: "tc-1", name: "Hooked", args: {} });
|
|
189
|
+
await router.processToolCalls([parsed], { turn: 1 });
|
|
190
|
+
|
|
191
|
+
expect(order).toEqual(["global-pre-skip"]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// --- Both global and per-tool post-hooks run ---
|
|
195
|
+
|
|
196
|
+
it("per-tool post-hook runs before global post-hook", async () => {
|
|
197
|
+
const order: string[] = [];
|
|
198
|
+
|
|
199
|
+
const hookedTool = defineTool({
|
|
200
|
+
name: "Hooked" as const,
|
|
201
|
+
description: "hooked tool",
|
|
202
|
+
schema: z.object({}),
|
|
203
|
+
handler: async () => ({ toolResponse: "ok", data: { value: 1 } }),
|
|
204
|
+
hooks: {
|
|
205
|
+
onPostToolUse: async () => {
|
|
206
|
+
order.push("tool-post");
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const router = createToolRouter({
|
|
212
|
+
tools: { Hooked: hookedTool } as const,
|
|
213
|
+
threadId: "t-1",
|
|
214
|
+
appendToolResult: appendSpy.fn,
|
|
215
|
+
hooks: {
|
|
216
|
+
onPostToolUse: async () => {
|
|
217
|
+
order.push("global-post");
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const parsed = router.parseToolCall({ id: "tc-1", name: "Hooked", args: {} });
|
|
223
|
+
await router.processToolCalls([parsed], { turn: 1 });
|
|
224
|
+
|
|
225
|
+
expect(order).toEqual(["tool-post", "global-post"]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// --- Per-tool failure hook takes precedence over global ---
|
|
229
|
+
|
|
230
|
+
it("per-tool failure hook takes precedence over global failure hook", async () => {
|
|
231
|
+
const failTool = defineTool({
|
|
232
|
+
name: "Fail" as const,
|
|
233
|
+
description: "fails",
|
|
234
|
+
schema: z.object({}),
|
|
235
|
+
handler: async (): Promise<ToolHandlerResponse<null>> => {
|
|
236
|
+
throw new Error("boom");
|
|
237
|
+
},
|
|
238
|
+
hooks: {
|
|
239
|
+
onPostToolUseFailure: async () => ({
|
|
240
|
+
fallbackContent: "tool-level recovery",
|
|
241
|
+
}),
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const globalHookSpy = vi.fn(async () => ({
|
|
246
|
+
fallbackContent: "global-level recovery",
|
|
247
|
+
}));
|
|
248
|
+
|
|
249
|
+
const router = createToolRouter({
|
|
250
|
+
tools: { Fail: failTool } as const,
|
|
251
|
+
threadId: "t-1",
|
|
252
|
+
appendToolResult: appendSpy.fn,
|
|
253
|
+
hooks: {
|
|
254
|
+
onPostToolUseFailure: globalHookSpy,
|
|
255
|
+
},
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
const parsed = router.parseToolCall({ id: "tc-1", name: "Fail", args: {} });
|
|
259
|
+
const results = await router.processToolCalls([parsed], { turn: 1 });
|
|
260
|
+
|
|
261
|
+
expect(at(appendSpy.calls, 0).content).toBe("tool-level recovery");
|
|
262
|
+
expect(at(results, 0).data).toEqual({ error: "Error: boom", recovered: true });
|
|
263
|
+
expect(globalHookSpy).not.toHaveBeenCalled();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// --- Pre-hook modifiedArgs from both global and per-tool ---
|
|
267
|
+
|
|
268
|
+
it("per-tool pre-hook modifiedArgs overrides global pre-hook modifiedArgs", async () => {
|
|
269
|
+
let receivedArgs: { text: string } | null = null;
|
|
270
|
+
|
|
271
|
+
const modTool = defineTool({
|
|
272
|
+
name: "Mod" as const,
|
|
273
|
+
description: "mod",
|
|
274
|
+
schema: z.object({ text: z.string() }),
|
|
275
|
+
handler: async (args: { text: string }) => {
|
|
276
|
+
receivedArgs = args;
|
|
277
|
+
return { toolResponse: args.text, data: null };
|
|
278
|
+
},
|
|
279
|
+
hooks: {
|
|
280
|
+
onPreToolUse: async () => ({
|
|
281
|
+
modifiedArgs: { text: "tool-modified" },
|
|
282
|
+
}),
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const router = createToolRouter({
|
|
287
|
+
tools: { Mod: modTool } as const,
|
|
288
|
+
threadId: "t-1",
|
|
289
|
+
appendToolResult: appendSpy.fn,
|
|
290
|
+
hooks: {
|
|
291
|
+
onPreToolUse: async () => ({
|
|
292
|
+
modifiedArgs: { text: "global-modified" },
|
|
293
|
+
}),
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const parsed = router.parseToolCall({
|
|
298
|
+
id: "tc-1",
|
|
299
|
+
name: "Mod",
|
|
300
|
+
args: { text: "original" },
|
|
301
|
+
});
|
|
302
|
+
await router.processToolCalls([parsed], { turn: 1 });
|
|
303
|
+
|
|
304
|
+
expect(receivedArgs).toEqual({ text: "tool-modified" });
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// --- Multiple unknown tool calls in parallel ---
|
|
308
|
+
|
|
309
|
+
it("handles multiple unknown tools in parallel mode", async () => {
|
|
310
|
+
const echoTool = defineTool({
|
|
311
|
+
name: "Echo" as const,
|
|
312
|
+
description: "echo",
|
|
313
|
+
schema: z.object({ text: z.string() }),
|
|
314
|
+
handler: async (args: { text: string }) => ({
|
|
315
|
+
toolResponse: args.text,
|
|
316
|
+
data: { echoed: args.text },
|
|
317
|
+
}),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const router = createToolRouter({
|
|
321
|
+
tools: { Echo: echoTool } as const,
|
|
322
|
+
threadId: "t-1",
|
|
323
|
+
appendToolResult: appendSpy.fn,
|
|
324
|
+
parallel: true,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
328
|
+
const results = await router.processToolCalls([
|
|
329
|
+
{ id: "tc-1", name: "Unknown1", args: {} },
|
|
330
|
+
{ id: "tc-2", name: "Unknown2", args: {} },
|
|
331
|
+
] as any);
|
|
332
|
+
|
|
333
|
+
expect(results).toHaveLength(2);
|
|
334
|
+
expect(at(results, 0).data).toEqual({ error: "Unknown tool: Unknown1" });
|
|
335
|
+
expect(at(results, 1).data).toEqual({ error: "Unknown tool: Unknown2" });
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// --- Plugins can override tools by name ---
|
|
339
|
+
|
|
340
|
+
it("plugin tool with same name as registered tool takes last-write precedence", async () => {
|
|
341
|
+
const baseTool = defineTool({
|
|
342
|
+
name: "MyTool" as const,
|
|
343
|
+
description: "base version",
|
|
344
|
+
schema: z.object({}),
|
|
345
|
+
handler: async () => ({
|
|
346
|
+
toolResponse: "base",
|
|
347
|
+
data: { source: "base" },
|
|
348
|
+
}),
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const pluginTool: ToolMap[string] = {
|
|
352
|
+
name: "MyTool",
|
|
353
|
+
description: "plugin version",
|
|
354
|
+
schema: z.object({}),
|
|
355
|
+
handler: async () => ({
|
|
356
|
+
toolResponse: "plugin",
|
|
357
|
+
data: { source: "plugin" },
|
|
358
|
+
}),
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
const router = createToolRouter({
|
|
362
|
+
tools: { MyTool: baseTool } as const,
|
|
363
|
+
threadId: "t-1",
|
|
364
|
+
appendToolResult: appendSpy.fn,
|
|
365
|
+
plugins: [pluginTool],
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const parsed = router.parseToolCall({ id: "tc-1", name: "MyTool", args: {} });
|
|
369
|
+
const results = await router.processToolCalls([parsed]);
|
|
370
|
+
|
|
371
|
+
expect(at(results, 0).data).toEqual({ source: "plugin" });
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// --- processToolCallsByName with no matching calls ---
|
|
375
|
+
|
|
376
|
+
it("processToolCallsByName returns empty when no calls match", async () => {
|
|
377
|
+
const echoTool = defineTool({
|
|
378
|
+
name: "Echo" as const,
|
|
379
|
+
description: "echo",
|
|
380
|
+
schema: z.object({ text: z.string() }),
|
|
381
|
+
handler: async (args: { text: string }) => ({
|
|
382
|
+
toolResponse: args.text,
|
|
383
|
+
data: { echoed: args.text },
|
|
384
|
+
}),
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const router = createToolRouter({
|
|
388
|
+
tools: { Echo: echoTool } as const,
|
|
389
|
+
threadId: "t-1",
|
|
390
|
+
appendToolResult: appendSpy.fn,
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
const results = await router.processToolCallsByName(
|
|
394
|
+
[],
|
|
395
|
+
"Echo",
|
|
396
|
+
async () => ({ toolResponse: "ok", data: null }),
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
expect(results).toEqual([]);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// --- Tool handler returning complex ToolMessageContent ---
|
|
403
|
+
|
|
404
|
+
it("handles tool response as ContentPart array", async () => {
|
|
405
|
+
const complexTool = defineTool({
|
|
406
|
+
name: "Complex" as const,
|
|
407
|
+
description: "returns complex content",
|
|
408
|
+
schema: z.object({}),
|
|
409
|
+
handler: async () => ({
|
|
410
|
+
toolResponse: [
|
|
411
|
+
{ type: "text", text: "Part 1" },
|
|
412
|
+
{ type: "text", text: "Part 2" },
|
|
413
|
+
],
|
|
414
|
+
data: null,
|
|
415
|
+
}),
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const router = createToolRouter({
|
|
419
|
+
tools: { Complex: complexTool } as const,
|
|
420
|
+
threadId: "t-1",
|
|
421
|
+
appendToolResult: appendSpy.fn,
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
const parsed = router.parseToolCall({ id: "tc-1", name: "Complex", args: {} });
|
|
425
|
+
await router.processToolCalls([parsed]);
|
|
426
|
+
|
|
427
|
+
const appended = at(appendSpy.calls, 0);
|
|
428
|
+
expect(Array.isArray(appended.content)).toBe(true);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// --- Synchronous handler ---
|
|
432
|
+
|
|
433
|
+
it("supports synchronous handler (not returning a promise)", async () => {
|
|
434
|
+
const syncTool = defineTool({
|
|
435
|
+
name: "Sync" as const,
|
|
436
|
+
description: "sync handler",
|
|
437
|
+
schema: z.object({ n: z.number() }),
|
|
438
|
+
handler: (args: { n: number }): ToolHandlerResponse<{ doubled: number }> => ({
|
|
439
|
+
toolResponse: `${args.n * 2}`,
|
|
440
|
+
data: { doubled: args.n * 2 },
|
|
441
|
+
}),
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const router = createToolRouter({
|
|
445
|
+
tools: { Sync: syncTool } as const,
|
|
446
|
+
threadId: "t-1",
|
|
447
|
+
appendToolResult: appendSpy.fn,
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
const parsed = router.parseToolCall({ id: "tc-1", name: "Sync", args: { n: 5 } });
|
|
451
|
+
const results = await router.processToolCalls([parsed]);
|
|
452
|
+
|
|
453
|
+
expect(at(results, 0).data).toEqual({ doubled: 10 });
|
|
454
|
+
expect(at(appendSpy.calls, 0).content).toBe("10");
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// --- Default turn is 0 when no context provided ---
|
|
458
|
+
|
|
459
|
+
it("default turn is 0 when processToolCalls context is omitted", async () => {
|
|
460
|
+
let capturedTurn: number | undefined;
|
|
461
|
+
|
|
462
|
+
const spyTool = defineTool({
|
|
463
|
+
name: "Spy" as const,
|
|
464
|
+
description: "spy",
|
|
465
|
+
schema: z.object({}),
|
|
466
|
+
handler: async () => ({ toolResponse: "ok", data: null }),
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const router = createToolRouter({
|
|
470
|
+
tools: { Spy: spyTool } as const,
|
|
471
|
+
threadId: "t-1",
|
|
472
|
+
appendToolResult: appendSpy.fn,
|
|
473
|
+
hooks: {
|
|
474
|
+
onPostToolUse: async ({ turn }) => {
|
|
475
|
+
capturedTurn = turn;
|
|
476
|
+
},
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
const parsed = router.parseToolCall({ id: "tc-1", name: "Spy", args: {} });
|
|
481
|
+
await router.processToolCalls([parsed]);
|
|
482
|
+
|
|
483
|
+
expect(capturedTurn).toBe(0);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
// --- Per-tool failure hook suppress ---
|
|
487
|
+
|
|
488
|
+
it("per-tool failure hook suppress appends JSON error content", async () => {
|
|
489
|
+
const suppressTool = defineTool({
|
|
490
|
+
name: "Suppress" as const,
|
|
491
|
+
description: "suppresses errors",
|
|
492
|
+
schema: z.object({}),
|
|
493
|
+
handler: async (): Promise<ToolHandlerResponse<null>> => {
|
|
494
|
+
throw new Error("suppressed error");
|
|
495
|
+
},
|
|
496
|
+
hooks: {
|
|
497
|
+
onPostToolUseFailure: async () => ({ suppress: true }),
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const router = createToolRouter({
|
|
502
|
+
tools: { Suppress: suppressTool } as const,
|
|
503
|
+
threadId: "t-1",
|
|
504
|
+
appendToolResult: appendSpy.fn,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const parsed = router.parseToolCall({ id: "tc-1", name: "Suppress", args: {} });
|
|
508
|
+
const results = await router.processToolCalls([parsed], { turn: 1 });
|
|
509
|
+
|
|
510
|
+
expect(at(results, 0).data).toEqual({
|
|
511
|
+
error: "Error: suppressed error",
|
|
512
|
+
suppressed: true,
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const content = at(appendSpy.calls, 0).content;
|
|
516
|
+
expect(typeof content === "string").toBe(true);
|
|
517
|
+
const parsed2 = JSON.parse(content as string);
|
|
518
|
+
expect(parsed2.suppressed).toBe(true);
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// --- Zod coercion during parsing ---
|
|
522
|
+
|
|
523
|
+
it("zod schema coercion is applied during parseToolCall", () => {
|
|
524
|
+
const coerceTool = defineTool({
|
|
525
|
+
name: "Coerce" as const,
|
|
526
|
+
description: "coerces",
|
|
527
|
+
schema: z.object({
|
|
528
|
+
count: z.number().default(10),
|
|
529
|
+
label: z.string().optional(),
|
|
530
|
+
}),
|
|
531
|
+
handler: async () => ({ toolResponse: "ok", data: null }),
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
const router = createToolRouter({
|
|
535
|
+
tools: { Coerce: coerceTool } as const,
|
|
536
|
+
threadId: "t-1",
|
|
537
|
+
appendToolResult: appendSpy.fn,
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
const parsed = router.parseToolCall({
|
|
541
|
+
id: "tc-1",
|
|
542
|
+
name: "Coerce",
|
|
543
|
+
args: {},
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
expect(parsed.args).toEqual({ count: 10 });
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// --- getToolNames returns tool.name not the map key ---
|
|
550
|
+
|
|
551
|
+
it("getToolNames uses tool name property not map key", () => {
|
|
552
|
+
const tool = defineTool({
|
|
553
|
+
name: "ActualName" as const,
|
|
554
|
+
description: "named differently",
|
|
555
|
+
schema: z.object({}),
|
|
556
|
+
handler: async () => ({ toolResponse: "ok", data: null }),
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
const router = createToolRouter({
|
|
560
|
+
tools: { MapKey: tool } as const,
|
|
561
|
+
threadId: "t-1",
|
|
562
|
+
appendToolResult: appendSpy.fn,
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
expect(router.getToolNames()).toContain("ActualName");
|
|
566
|
+
expect(router.hasTool("ActualName")).toBe(true);
|
|
567
|
+
expect(router.hasTool("MapKey")).toBe(false);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// --- Non-Error thrown by handler ---
|
|
571
|
+
|
|
572
|
+
it("handles non-Error object thrown by handler", async () => {
|
|
573
|
+
const throwStringTool = defineTool({
|
|
574
|
+
name: "ThrowString" as const,
|
|
575
|
+
description: "throws string",
|
|
576
|
+
schema: z.object({}),
|
|
577
|
+
handler: async (): Promise<ToolHandlerResponse<null>> => {
|
|
578
|
+
throw "string error";
|
|
579
|
+
},
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const router = createToolRouter({
|
|
583
|
+
tools: { ThrowString: throwStringTool } as const,
|
|
584
|
+
threadId: "t-1",
|
|
585
|
+
appendToolResult: appendSpy.fn,
|
|
586
|
+
hooks: {
|
|
587
|
+
onPostToolUseFailure: async () => ({
|
|
588
|
+
fallbackContent: "recovered from string throw",
|
|
589
|
+
}),
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
const parsed = router.parseToolCall({ id: "tc-1", name: "ThrowString", args: {} });
|
|
594
|
+
const results = await router.processToolCalls([parsed], { turn: 1 });
|
|
595
|
+
|
|
596
|
+
expect(at(results, 0).data).toEqual({
|
|
597
|
+
error: "string error",
|
|
598
|
+
recovered: true,
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
describe("hasNoOtherToolCalls", () => {
|
|
604
|
+
it("returns true when all calls match the excluded name", () => {
|
|
605
|
+
const calls = [
|
|
606
|
+
{ id: "1", name: "AskUser", args: {} },
|
|
607
|
+
{ id: "2", name: "AskUser", args: {} },
|
|
608
|
+
];
|
|
609
|
+
expect(hasNoOtherToolCalls(calls as any, "AskUser" as any)).toBe(true);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("returns false when other calls exist", () => {
|
|
613
|
+
const calls = [
|
|
614
|
+
{ id: "1", name: "AskUser", args: {} },
|
|
615
|
+
{ id: "2", name: "Echo", args: {} },
|
|
616
|
+
];
|
|
617
|
+
expect(hasNoOtherToolCalls(calls as any, "AskUser" as any)).toBe(false);
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("returns true for empty array", () => {
|
|
621
|
+
expect(hasNoOtherToolCalls([] as any, "AskUser" as any)).toBe(true);
|
|
622
|
+
});
|
|
623
|
+
});
|