zeitlich 0.2.15 → 0.2.17
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 +125 -64
- 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 +83 -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 +80 -38
- package/dist/adapters/sandbox/virtual/index.js.map +1 -1
- package/dist/adapters/thread/google-genai/index.d.cts +2 -2
- package/dist/adapters/thread/google-genai/index.d.ts +2 -2
- package/dist/adapters/thread/langchain/index.cjs +2 -2
- package/dist/adapters/thread/langchain/index.cjs.map +1 -1
- package/dist/adapters/thread/langchain/index.d.cts +2 -2
- package/dist/adapters/thread/langchain/index.d.ts +2 -2
- package/dist/adapters/thread/langchain/index.js +2 -2
- package/dist/adapters/thread/langchain/index.js.map +1 -1
- package/dist/index.cjs +102 -10
- 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 +98 -11
- package/dist/index.js.map +1 -1
- package/dist/{types-CwwgQ_9H.d.ts → queries-BlC1I3DK.d.ts} +48 -3
- package/dist/{types-BVP87m_W.d.cts → queries-DlJ3jE48.d.cts} +48 -3
- 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-Dje1TdH6.d.cts → types-Bh-BbfCp.d.cts} +31 -12
- package/dist/{types-BWvIYK28.d.ts → types-NkiAxU4t.d.ts} +31 -12
- package/dist/workflow.cjs +102 -10
- package/dist/workflow.cjs.map +1 -1
- package/dist/workflow.d.cts +114 -40
- package/dist/workflow.d.ts +114 -40
- package/dist/workflow.js +98 -11
- package/dist/workflow.js.map +1 -1
- package/package.json +1 -1
- 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 +7 -3
- package/src/adapters/sandbox/virtual/provider.ts +5 -2
- package/src/adapters/sandbox/virtual/queries.ts +97 -0
- package/src/adapters/sandbox/virtual/types.ts +3 -0
- package/src/adapters/sandbox/virtual/with-virtual-sandbox.ts +4 -3
- package/src/adapters/thread/langchain/activities.ts +7 -5
- 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 +853 -0
- package/src/lib/session/session.ts +5 -4
- package/src/lib/skills/skills.integration.test.ts +308 -0
- package/src/lib/state/manager.integration.test.ts +342 -0
- package/src/lib/subagent/define.ts +34 -47
- package/src/lib/subagent/handler.ts +9 -6
- package/src/lib/subagent/index.ts +4 -1
- package/src/lib/subagent/subagent.integration.test.ts +573 -0
- package/src/lib/subagent/types.ts +40 -10
- package/src/lib/subagent/workflow.ts +114 -0
- package/src/lib/thread/id.test.ts +50 -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
- package/src/lib/workflow.test.ts +131 -0
- package/src/lib/workflow.ts +45 -0
- package/src/workflow.ts +12 -2
|
@@ -0,0 +1,699 @@
|
|
|
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 } from "./router";
|
|
32
|
+
import type {
|
|
33
|
+
ToolMap,
|
|
34
|
+
ToolHandlerResponse,
|
|
35
|
+
RouterContext,
|
|
36
|
+
AppendToolResultFn,
|
|
37
|
+
} from "./types";
|
|
38
|
+
import type { ToolResultConfig } from "../types";
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Test tool definitions
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
const echoTool = defineTool({
|
|
45
|
+
name: "Echo" as const,
|
|
46
|
+
description: "Echoes back the input",
|
|
47
|
+
schema: z.object({ text: z.string() }),
|
|
48
|
+
handler: async (args: { text: string }): Promise<ToolHandlerResponse<{ echoed: string }>> => ({
|
|
49
|
+
toolResponse: `Echo: ${args.text}`,
|
|
50
|
+
data: { echoed: args.text },
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const mathTool = defineTool({
|
|
55
|
+
name: "Add" as const,
|
|
56
|
+
description: "Adds two numbers",
|
|
57
|
+
schema: z.object({ a: z.number(), b: z.number() }),
|
|
58
|
+
handler: async (args: { a: number; b: number }): Promise<ToolHandlerResponse<{ sum: number }>> => ({
|
|
59
|
+
toolResponse: `Sum: ${args.a + args.b}`,
|
|
60
|
+
data: { sum: args.a + args.b },
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const failingTool = defineTool({
|
|
65
|
+
name: "Fail" as const,
|
|
66
|
+
description: "Always fails",
|
|
67
|
+
schema: z.object({ reason: z.string() }),
|
|
68
|
+
handler: async (args: { reason: string }): Promise<ToolHandlerResponse<null>> => {
|
|
69
|
+
throw new Error(args.reason);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Helpers
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
function at<T>(arr: T[], index: number): T {
|
|
78
|
+
const val = arr[index];
|
|
79
|
+
if (val === undefined) throw new Error(`Index ${index} out of bounds`);
|
|
80
|
+
return val;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function createTools() {
|
|
84
|
+
return { Echo: echoTool, Add: mathTool } as const;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function createAppendSpy() {
|
|
88
|
+
const calls: ToolResultConfig[] = [];
|
|
89
|
+
const fn: AppendToolResultFn = async (config) => {
|
|
90
|
+
calls.push(config);
|
|
91
|
+
};
|
|
92
|
+
return { fn, calls };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Tests
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
describe("createToolRouter integration", () => {
|
|
100
|
+
let appendSpy: ReturnType<typeof createAppendSpy>;
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
appendSpy = createAppendSpy();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// --- Basic setup ---
|
|
107
|
+
|
|
108
|
+
it("exposes registered tool definitions", () => {
|
|
109
|
+
const router = createToolRouter({
|
|
110
|
+
tools: createTools(),
|
|
111
|
+
threadId: "t-1",
|
|
112
|
+
appendToolResult: appendSpy.fn,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(router.hasTools()).toBe(true);
|
|
116
|
+
expect(router.getToolNames()).toContain("Echo");
|
|
117
|
+
expect(router.getToolNames()).toContain("Add");
|
|
118
|
+
expect(router.hasTool("Echo")).toBe(true);
|
|
119
|
+
expect(router.hasTool("NonExistent")).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns tool definitions without handlers", () => {
|
|
123
|
+
const router = createToolRouter({
|
|
124
|
+
tools: createTools(),
|
|
125
|
+
threadId: "t-1",
|
|
126
|
+
appendToolResult: appendSpy.fn,
|
|
127
|
+
});
|
|
128
|
+
const defs = router.getToolDefinitions();
|
|
129
|
+
expect(defs).toHaveLength(2);
|
|
130
|
+
for (const def of defs) {
|
|
131
|
+
expect(def).toHaveProperty("name");
|
|
132
|
+
expect(def).toHaveProperty("description");
|
|
133
|
+
expect(def).toHaveProperty("schema");
|
|
134
|
+
expect(def).not.toHaveProperty("handler");
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// --- parseToolCall ---
|
|
139
|
+
|
|
140
|
+
it("parses valid tool calls with argument validation", () => {
|
|
141
|
+
const router = createToolRouter({
|
|
142
|
+
tools: createTools(),
|
|
143
|
+
threadId: "t-1",
|
|
144
|
+
appendToolResult: appendSpy.fn,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const parsed = router.parseToolCall({
|
|
148
|
+
id: "tc-1",
|
|
149
|
+
name: "Echo",
|
|
150
|
+
args: { text: "hello" },
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(parsed.id).toBe("tc-1");
|
|
154
|
+
expect(parsed.name).toBe("Echo");
|
|
155
|
+
expect(parsed.args).toEqual({ text: "hello" });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("rejects unknown tool names", () => {
|
|
159
|
+
const router = createToolRouter({
|
|
160
|
+
tools: createTools(),
|
|
161
|
+
threadId: "t-1",
|
|
162
|
+
appendToolResult: appendSpy.fn,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
expect(() =>
|
|
166
|
+
router.parseToolCall({ id: "tc-1", name: "Unknown", args: {} }),
|
|
167
|
+
).toThrow("Tool Unknown not found");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("rejects invalid arguments", () => {
|
|
171
|
+
const router = createToolRouter({
|
|
172
|
+
tools: createTools(),
|
|
173
|
+
threadId: "t-1",
|
|
174
|
+
appendToolResult: appendSpy.fn,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(() =>
|
|
178
|
+
router.parseToolCall({ id: "tc-1", name: "Echo", args: { text: 123 } }),
|
|
179
|
+
).toThrow();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// --- processToolCalls ---
|
|
183
|
+
|
|
184
|
+
it("processes a single tool call and appends the result", async () => {
|
|
185
|
+
const router = createToolRouter({
|
|
186
|
+
tools: createTools(),
|
|
187
|
+
threadId: "t-1",
|
|
188
|
+
appendToolResult: appendSpy.fn,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const parsed = router.parseToolCall({
|
|
192
|
+
id: "tc-1",
|
|
193
|
+
name: "Echo",
|
|
194
|
+
args: { text: "world" },
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const results = await router.processToolCalls([parsed]);
|
|
198
|
+
|
|
199
|
+
expect(results).toHaveLength(1);
|
|
200
|
+
expect(at(results, 0).name).toBe("Echo");
|
|
201
|
+
expect(at(results, 0).data).toEqual({ echoed: "world" });
|
|
202
|
+
|
|
203
|
+
expect(appendSpy.calls).toHaveLength(1);
|
|
204
|
+
expect(at(appendSpy.calls, 0).toolCallId).toBe("tc-1");
|
|
205
|
+
expect(at(appendSpy.calls, 0).toolName).toBe("Echo");
|
|
206
|
+
expect(at(appendSpy.calls, 0).content).toBe("Echo: world");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("processes multiple tool calls in parallel", async () => {
|
|
210
|
+
const order: string[] = [];
|
|
211
|
+
const slowEcho = defineTool({
|
|
212
|
+
name: "Echo" as const,
|
|
213
|
+
description: "slow echo",
|
|
214
|
+
schema: z.object({ text: z.string() }),
|
|
215
|
+
handler: async (args: { text: string }) => {
|
|
216
|
+
order.push(`start-echo-${args.text}`);
|
|
217
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
218
|
+
order.push(`end-echo-${args.text}`);
|
|
219
|
+
return { toolResponse: args.text, data: { echoed: args.text } };
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const router = createToolRouter({
|
|
224
|
+
tools: { Echo: slowEcho, Add: mathTool } as const,
|
|
225
|
+
threadId: "t-1",
|
|
226
|
+
appendToolResult: appendSpy.fn,
|
|
227
|
+
parallel: true,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const calls = [
|
|
231
|
+
router.parseToolCall({ id: "tc-1", name: "Echo", args: { text: "a" } }),
|
|
232
|
+
router.parseToolCall({ id: "tc-2", name: "Echo", args: { text: "b" } }),
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const results = await router.processToolCalls(calls);
|
|
236
|
+
expect(results).toHaveLength(2);
|
|
237
|
+
// Both starts should happen before both ends in parallel
|
|
238
|
+
expect(order[0]).toBe("start-echo-a");
|
|
239
|
+
expect(order[1]).toBe("start-echo-b");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("processes multiple tool calls sequentially", async () => {
|
|
243
|
+
const order: string[] = [];
|
|
244
|
+
const slowEcho = defineTool({
|
|
245
|
+
name: "Echo" as const,
|
|
246
|
+
description: "slow echo",
|
|
247
|
+
schema: z.object({ text: z.string() }),
|
|
248
|
+
handler: async (args: { text: string }) => {
|
|
249
|
+
order.push(`start-echo-${args.text}`);
|
|
250
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
251
|
+
order.push(`end-echo-${args.text}`);
|
|
252
|
+
return { toolResponse: args.text, data: { echoed: args.text } };
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const router = createToolRouter({
|
|
257
|
+
tools: { Echo: slowEcho } as const,
|
|
258
|
+
threadId: "t-1",
|
|
259
|
+
appendToolResult: appendSpy.fn,
|
|
260
|
+
parallel: false,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const calls = [
|
|
264
|
+
router.parseToolCall({ id: "tc-1", name: "Echo", args: { text: "a" } }),
|
|
265
|
+
router.parseToolCall({ id: "tc-2", name: "Echo", args: { text: "b" } }),
|
|
266
|
+
];
|
|
267
|
+
|
|
268
|
+
const results = await router.processToolCalls(calls);
|
|
269
|
+
expect(results).toHaveLength(2);
|
|
270
|
+
// Sequential: first finishes before second starts
|
|
271
|
+
expect(order).toEqual([
|
|
272
|
+
"start-echo-a",
|
|
273
|
+
"end-echo-a",
|
|
274
|
+
"start-echo-b",
|
|
275
|
+
"end-echo-b",
|
|
276
|
+
]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("handles unknown tools gracefully during processing", async () => {
|
|
280
|
+
const router = createToolRouter({
|
|
281
|
+
tools: createTools(),
|
|
282
|
+
threadId: "t-1",
|
|
283
|
+
appendToolResult: appendSpy.fn,
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// Force an unknown tool call (bypassing parseToolCall validation)
|
|
287
|
+
const results = await router.processToolCalls([
|
|
288
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
289
|
+
{ id: "tc-1", name: "NonExistent", args: {} } as any,
|
|
290
|
+
]);
|
|
291
|
+
|
|
292
|
+
expect(results).toHaveLength(1);
|
|
293
|
+
expect(at(results, 0).data).toEqual({ error: "Unknown tool: NonExistent" });
|
|
294
|
+
expect(at(appendSpy.calls, 0).content).toContain("Unknown tool");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// --- RouterContext ---
|
|
298
|
+
|
|
299
|
+
it("passes correct RouterContext to handlers", async () => {
|
|
300
|
+
let capturedCtx: RouterContext | null = null;
|
|
301
|
+
|
|
302
|
+
const spyTool = defineTool({
|
|
303
|
+
name: "Spy" as const,
|
|
304
|
+
description: "captures context",
|
|
305
|
+
schema: z.object({}),
|
|
306
|
+
handler: async (_args: Record<string, never>, ctx: RouterContext) => {
|
|
307
|
+
capturedCtx = ctx;
|
|
308
|
+
return { toolResponse: "ok", data: null };
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const router = createToolRouter({
|
|
313
|
+
tools: { Spy: spyTool } as const,
|
|
314
|
+
threadId: "thread-42",
|
|
315
|
+
appendToolResult: appendSpy.fn,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const parsed = router.parseToolCall({ id: "tc-99", name: "Spy", args: {} });
|
|
319
|
+
await router.processToolCalls([parsed], { sandboxId: "sandbox-1" });
|
|
320
|
+
|
|
321
|
+
expect(capturedCtx).toEqual(
|
|
322
|
+
expect.objectContaining({
|
|
323
|
+
threadId: "thread-42",
|
|
324
|
+
toolCallId: "tc-99",
|
|
325
|
+
toolName: "Spy",
|
|
326
|
+
sandboxId: "sandbox-1",
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// --- Hooks ---
|
|
332
|
+
|
|
333
|
+
it("pre-hook can skip tool execution", async () => {
|
|
334
|
+
const handlerSpy = vi.fn(async () => ({
|
|
335
|
+
toolResponse: "should not run",
|
|
336
|
+
data: null,
|
|
337
|
+
}));
|
|
338
|
+
|
|
339
|
+
const skipTool = defineTool({
|
|
340
|
+
name: "Skippable" as const,
|
|
341
|
+
description: "can be skipped",
|
|
342
|
+
schema: z.object({}),
|
|
343
|
+
handler: handlerSpy,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
const router = createToolRouter({
|
|
347
|
+
tools: { Skippable: skipTool } as const,
|
|
348
|
+
threadId: "t-1",
|
|
349
|
+
appendToolResult: appendSpy.fn,
|
|
350
|
+
hooks: {
|
|
351
|
+
onPreToolUse: async () => ({ skip: true }),
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const parsed = router.parseToolCall({ id: "tc-1", name: "Skippable", args: {} });
|
|
356
|
+
const results = await router.processToolCalls([parsed], { turn: 1 });
|
|
357
|
+
|
|
358
|
+
expect(handlerSpy).not.toHaveBeenCalled();
|
|
359
|
+
expect(results).toHaveLength(0);
|
|
360
|
+
expect(at(appendSpy.calls, 0).content).toContain("Skipped by PreToolUse hook");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("pre-hook can modify arguments", async () => {
|
|
364
|
+
let receivedArgs: { text: string } | null = null;
|
|
365
|
+
|
|
366
|
+
const modTool = defineTool({
|
|
367
|
+
name: "Echo" as const,
|
|
368
|
+
description: "echo",
|
|
369
|
+
schema: z.object({ text: z.string() }),
|
|
370
|
+
handler: async (args: { text: string }) => {
|
|
371
|
+
receivedArgs = args;
|
|
372
|
+
return { toolResponse: args.text, data: { echoed: args.text } };
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const router = createToolRouter({
|
|
377
|
+
tools: { Echo: modTool } as const,
|
|
378
|
+
threadId: "t-1",
|
|
379
|
+
appendToolResult: appendSpy.fn,
|
|
380
|
+
hooks: {
|
|
381
|
+
onPreToolUse: async () => ({
|
|
382
|
+
modifiedArgs: { text: "modified" },
|
|
383
|
+
}),
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const parsed = router.parseToolCall({
|
|
388
|
+
id: "tc-1",
|
|
389
|
+
name: "Echo",
|
|
390
|
+
args: { text: "original" },
|
|
391
|
+
});
|
|
392
|
+
await router.processToolCalls([parsed], { turn: 1 });
|
|
393
|
+
|
|
394
|
+
expect(receivedArgs).toEqual({ text: "modified" });
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("per-tool pre-hook can skip execution", async () => {
|
|
398
|
+
const handlerSpy = vi.fn(async () => ({
|
|
399
|
+
toolResponse: "nope",
|
|
400
|
+
data: null,
|
|
401
|
+
}));
|
|
402
|
+
|
|
403
|
+
const hookTool = defineTool({
|
|
404
|
+
name: "Hooked" as const,
|
|
405
|
+
description: "has per-tool hook",
|
|
406
|
+
schema: z.object({}),
|
|
407
|
+
handler: handlerSpy,
|
|
408
|
+
hooks: {
|
|
409
|
+
onPreToolUse: async () => ({ skip: true }),
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
const router = createToolRouter({
|
|
414
|
+
tools: { Hooked: hookTool } as const,
|
|
415
|
+
threadId: "t-1",
|
|
416
|
+
appendToolResult: appendSpy.fn,
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const parsed = router.parseToolCall({ id: "tc-1", name: "Hooked", args: {} });
|
|
420
|
+
const results = await router.processToolCalls([parsed], { turn: 1 });
|
|
421
|
+
|
|
422
|
+
expect(handlerSpy).not.toHaveBeenCalled();
|
|
423
|
+
expect(results).toHaveLength(0);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("post-hook receives result and timing info", async () => {
|
|
427
|
+
let hookData: { result: unknown; durationMs: number } | null = null;
|
|
428
|
+
|
|
429
|
+
const router = createToolRouter({
|
|
430
|
+
tools: createTools(),
|
|
431
|
+
threadId: "t-1",
|
|
432
|
+
appendToolResult: appendSpy.fn,
|
|
433
|
+
hooks: {
|
|
434
|
+
onPostToolUse: async ({ result, durationMs }) => {
|
|
435
|
+
hookData = { result, durationMs };
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const parsed = router.parseToolCall({
|
|
441
|
+
id: "tc-1",
|
|
442
|
+
name: "Add",
|
|
443
|
+
args: { a: 3, b: 4 },
|
|
444
|
+
});
|
|
445
|
+
await router.processToolCalls([parsed], { turn: 1 });
|
|
446
|
+
|
|
447
|
+
expect(hookData).not.toBeNull();
|
|
448
|
+
const data = hookData as unknown as { result: { data: unknown }; durationMs: number };
|
|
449
|
+
expect(data.result.data).toEqual({ sum: 7 });
|
|
450
|
+
expect(data.durationMs).toBeGreaterThanOrEqual(0);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("per-tool post-hook fires after execution", async () => {
|
|
454
|
+
let hookResult: { sum: number } | null = null;
|
|
455
|
+
|
|
456
|
+
const hookedMath = defineTool({
|
|
457
|
+
name: "Add" as const,
|
|
458
|
+
description: "adds numbers",
|
|
459
|
+
schema: z.object({ a: z.number(), b: z.number() }),
|
|
460
|
+
handler: async (args: { a: number; b: number }) => ({
|
|
461
|
+
toolResponse: `${args.a + args.b}`,
|
|
462
|
+
data: { sum: args.a + args.b },
|
|
463
|
+
}),
|
|
464
|
+
hooks: {
|
|
465
|
+
onPostToolUse: async ({ result }) => {
|
|
466
|
+
hookResult = result as { sum: number };
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const router = createToolRouter({
|
|
472
|
+
tools: { Add: hookedMath } as const,
|
|
473
|
+
threadId: "t-1",
|
|
474
|
+
appendToolResult: appendSpy.fn,
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
const parsed = router.parseToolCall({
|
|
478
|
+
id: "tc-1",
|
|
479
|
+
name: "Add",
|
|
480
|
+
args: { a: 10, b: 20 },
|
|
481
|
+
});
|
|
482
|
+
await router.processToolCalls([parsed], { turn: 1 });
|
|
483
|
+
|
|
484
|
+
expect(hookResult).toEqual({ sum: 30 });
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// --- Failure handling ---
|
|
488
|
+
|
|
489
|
+
it("global failure hook can recover with fallback content", async () => {
|
|
490
|
+
const router = createToolRouter({
|
|
491
|
+
tools: { Fail: failingTool } as const,
|
|
492
|
+
threadId: "t-1",
|
|
493
|
+
appendToolResult: appendSpy.fn,
|
|
494
|
+
hooks: {
|
|
495
|
+
onPostToolUseFailure: async () => ({
|
|
496
|
+
fallbackContent: "recovered gracefully",
|
|
497
|
+
}),
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const parsed = router.parseToolCall({
|
|
502
|
+
id: "tc-1",
|
|
503
|
+
name: "Fail",
|
|
504
|
+
args: { reason: "boom" },
|
|
505
|
+
});
|
|
506
|
+
const results = await router.processToolCalls([parsed], { turn: 1 });
|
|
507
|
+
|
|
508
|
+
expect(results).toHaveLength(1);
|
|
509
|
+
expect(at(results, 0).data).toEqual({ error: "Error: boom", recovered: true });
|
|
510
|
+
expect(at(appendSpy.calls, 0).content).toBe("recovered gracefully");
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("per-tool failure hook can suppress errors", async () => {
|
|
514
|
+
const suppressTool = defineTool({
|
|
515
|
+
name: "Fail" as const,
|
|
516
|
+
description: "fails but suppresses",
|
|
517
|
+
schema: z.object({ reason: z.string() }),
|
|
518
|
+
handler: async (args: { reason: string }): Promise<ToolHandlerResponse<null>> => {
|
|
519
|
+
throw new Error(args.reason);
|
|
520
|
+
},
|
|
521
|
+
hooks: {
|
|
522
|
+
onPostToolUseFailure: async () => ({ suppress: true }),
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
const router = createToolRouter({
|
|
527
|
+
tools: { Fail: suppressTool } as const,
|
|
528
|
+
threadId: "t-1",
|
|
529
|
+
appendToolResult: appendSpy.fn,
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const parsed = router.parseToolCall({
|
|
533
|
+
id: "tc-1",
|
|
534
|
+
name: "Fail",
|
|
535
|
+
args: { reason: "suppressed" },
|
|
536
|
+
});
|
|
537
|
+
const results = await router.processToolCalls([parsed], { turn: 1 });
|
|
538
|
+
|
|
539
|
+
expect(results).toHaveLength(1);
|
|
540
|
+
expect(at(results, 0).data).toEqual({
|
|
541
|
+
error: "Error: suppressed",
|
|
542
|
+
suppressed: true,
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it("throws when handler fails and no hook recovers", async () => {
|
|
547
|
+
const router = createToolRouter({
|
|
548
|
+
tools: { Fail: failingTool } as const,
|
|
549
|
+
threadId: "t-1",
|
|
550
|
+
appendToolResult: appendSpy.fn,
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const parsed = router.parseToolCall({
|
|
554
|
+
id: "tc-1",
|
|
555
|
+
name: "Fail",
|
|
556
|
+
args: { reason: "unrecoverable" },
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
await expect(
|
|
560
|
+
router.processToolCalls([parsed], { turn: 1 }),
|
|
561
|
+
).rejects.toThrow("unrecoverable");
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
// --- Disabled tools ---
|
|
565
|
+
|
|
566
|
+
it("excludes disabled tools from definitions and parsing", () => {
|
|
567
|
+
const disabledTool = defineTool({
|
|
568
|
+
name: "Disabled" as const,
|
|
569
|
+
description: "off",
|
|
570
|
+
schema: z.object({}),
|
|
571
|
+
handler: async () => ({ toolResponse: "nope", data: null }),
|
|
572
|
+
enabled: false,
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const router = createToolRouter({
|
|
576
|
+
tools: { Echo: echoTool, Disabled: disabledTool } as const,
|
|
577
|
+
threadId: "t-1",
|
|
578
|
+
appendToolResult: appendSpy.fn,
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
expect(router.hasTool("Disabled")).toBe(false);
|
|
582
|
+
expect(router.getToolNames()).not.toContain("Disabled");
|
|
583
|
+
expect(() =>
|
|
584
|
+
router.parseToolCall({ id: "tc-1", name: "Disabled", args: {} }),
|
|
585
|
+
).toThrow("Tool Disabled not found");
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// --- Plugins ---
|
|
589
|
+
|
|
590
|
+
it("registers plugins alongside tools", async () => {
|
|
591
|
+
const pluginTool: ToolMap[string] = {
|
|
592
|
+
name: "PluginTool",
|
|
593
|
+
description: "added via plugin",
|
|
594
|
+
schema: z.object({ input: z.string() }),
|
|
595
|
+
handler: async (args: { input: string }) => ({
|
|
596
|
+
toolResponse: `plugin: ${args.input}`,
|
|
597
|
+
data: { input: args.input },
|
|
598
|
+
}),
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const router = createToolRouter({
|
|
602
|
+
tools: createTools(),
|
|
603
|
+
threadId: "t-1",
|
|
604
|
+
appendToolResult: appendSpy.fn,
|
|
605
|
+
plugins: [pluginTool],
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
expect(router.hasTool("PluginTool")).toBe(true);
|
|
609
|
+
expect(router.getToolNames()).toContain("PluginTool");
|
|
610
|
+
|
|
611
|
+
const parsed = router.parseToolCall({
|
|
612
|
+
id: "tc-1",
|
|
613
|
+
name: "PluginTool",
|
|
614
|
+
args: { input: "hello" },
|
|
615
|
+
});
|
|
616
|
+
const results = await router.processToolCalls([parsed]);
|
|
617
|
+
expect(at(results, 0).data).toEqual({ input: "hello" });
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// --- processToolCallsByName ---
|
|
621
|
+
|
|
622
|
+
it("processToolCallsByName filters and processes matching calls", async () => {
|
|
623
|
+
const router = createToolRouter({
|
|
624
|
+
tools: createTools(),
|
|
625
|
+
threadId: "t-1",
|
|
626
|
+
appendToolResult: appendSpy.fn,
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const calls = [
|
|
630
|
+
router.parseToolCall({ id: "tc-1", name: "Echo", args: { text: "a" } }),
|
|
631
|
+
router.parseToolCall({ id: "tc-2", name: "Add", args: { a: 1, b: 2 } }),
|
|
632
|
+
router.parseToolCall({ id: "tc-3", name: "Echo", args: { text: "b" } }),
|
|
633
|
+
];
|
|
634
|
+
|
|
635
|
+
const results = await router.processToolCallsByName(
|
|
636
|
+
calls,
|
|
637
|
+
"Echo",
|
|
638
|
+
async (args: { text: string }) => ({
|
|
639
|
+
toolResponse: `custom: ${args.text}`,
|
|
640
|
+
data: { custom: args.text },
|
|
641
|
+
}),
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
expect(results).toHaveLength(2);
|
|
645
|
+
expect(at(results, 0).name).toBe("Echo");
|
|
646
|
+
expect(at(results, 0).data).toEqual({ custom: "a" });
|
|
647
|
+
expect(at(results, 1).data).toEqual({ custom: "b" });
|
|
648
|
+
expect(appendSpy.calls).toHaveLength(2);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
// --- filterByName / hasToolCall / getResultsByName ---
|
|
652
|
+
|
|
653
|
+
it("utility methods work correctly", async () => {
|
|
654
|
+
const router = createToolRouter({
|
|
655
|
+
tools: createTools(),
|
|
656
|
+
threadId: "t-1",
|
|
657
|
+
appendToolResult: appendSpy.fn,
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
const calls = [
|
|
661
|
+
router.parseToolCall({ id: "tc-1", name: "Echo", args: { text: "a" } }),
|
|
662
|
+
router.parseToolCall({ id: "tc-2", name: "Add", args: { a: 1, b: 2 } }),
|
|
663
|
+
];
|
|
664
|
+
|
|
665
|
+
expect(router.filterByName(calls, "Echo")).toHaveLength(1);
|
|
666
|
+
expect(router.hasToolCall(calls, "Echo")).toBe(true);
|
|
667
|
+
expect(router.hasToolCall(calls, "Add")).toBe(true);
|
|
668
|
+
|
|
669
|
+
const results = await router.processToolCalls(calls);
|
|
670
|
+
expect(router.getResultsByName(results, "Echo")).toHaveLength(1);
|
|
671
|
+
expect(router.getResultsByName(results, "Add")).toHaveLength(1);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// --- resultAppended flag ---
|
|
675
|
+
|
|
676
|
+
it("skips appendToolResult when handler sets resultAppended", async () => {
|
|
677
|
+
const selfAppendTool = defineTool({
|
|
678
|
+
name: "SelfAppend" as const,
|
|
679
|
+
description: "appends result itself",
|
|
680
|
+
schema: z.object({}),
|
|
681
|
+
handler: async () => ({
|
|
682
|
+
toolResponse: "self-appended",
|
|
683
|
+
data: null,
|
|
684
|
+
resultAppended: true,
|
|
685
|
+
}),
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
const router = createToolRouter({
|
|
689
|
+
tools: { SelfAppend: selfAppendTool } as const,
|
|
690
|
+
threadId: "t-1",
|
|
691
|
+
appendToolResult: appendSpy.fn,
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
const parsed = router.parseToolCall({ id: "tc-1", name: "SelfAppend", args: {} });
|
|
695
|
+
await router.processToolCalls([parsed]);
|
|
696
|
+
|
|
697
|
+
expect(appendSpy.calls).toHaveLength(0);
|
|
698
|
+
});
|
|
699
|
+
});
|