@polderlabs/bizar-plugin 0.5.4
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/LICENSE +21 -0
- package/README.md +448 -0
- package/bun.lock +88 -0
- package/index.ts +1113 -0
- package/package.json +42 -0
- package/scripts/check-forbidden-imports.sh +33 -0
- package/src/background-state.ts +463 -0
- package/src/background.ts +964 -0
- package/src/commands-impl.ts +369 -0
- package/src/commands.ts +880 -0
- package/src/event-stream.ts +574 -0
- package/src/fingerprint.ts +120 -0
- package/src/handoff.ts +79 -0
- package/src/http-client.ts +467 -0
- package/src/logger.ts +144 -0
- package/src/loop.ts +176 -0
- package/src/options.ts +421 -0
- package/src/plan-fs.ts +323 -0
- package/src/report.ts +178 -0
- package/src/research-prompt.ts +35 -0
- package/src/serve.ts +476 -0
- package/src/settings.ts +349 -0
- package/src/state.ts +298 -0
- package/src/tools/bg-collect.ts +104 -0
- package/src/tools/bg-get-comments.ts +239 -0
- package/src/tools/bg-kill.ts +87 -0
- package/src/tools/bg-spawn.ts +263 -0
- package/src/tools/bg-status.ts +99 -0
- package/src/tools/plan-action.ts +767 -0
- package/src/tools/wait-for-feedback.ts +402 -0
- package/tests/attach-handler-bug.test.ts +166 -0
- package/tests/background-state.test.ts +277 -0
- package/tests/background.test.ts +402 -0
- package/tests/block.test.ts +193 -0
- package/tests/canonical-key-order.test.ts +71 -0
- package/tests/commands-impl.test.ts +442 -0
- package/tests/commands.test.ts +548 -0
- package/tests/config.test.ts +122 -0
- package/tests/dispose.test.ts +336 -0
- package/tests/event-stream.test.ts +409 -0
- package/tests/event.test.ts +262 -0
- package/tests/fingerprint.test.ts +161 -0
- package/tests/http-client.test.ts +403 -0
- package/tests/init-helpers.test.ts +203 -0
- package/tests/integration/slash-command.test.ts +348 -0
- package/tests/integration/tool-routing.test.ts +314 -0
- package/tests/loop.test.ts +397 -0
- package/tests/options.test.ts +274 -0
- package/tests/serve.test.ts +335 -0
- package/tests/settings.test.ts +351 -0
- package/tests/stall-think.test.ts +749 -0
- package/tests/state.test.ts +275 -0
- package/tests/tools/bg-collect.test.ts +337 -0
- package/tests/tools/bg-get-comments.test.ts +485 -0
- package/tests/tools/bg-kill.test.ts +231 -0
- package/tests/tools/bg-spawn.test.ts +311 -0
- package/tests/tools/bg-status.test.ts +216 -0
- package/tests/tools/plan-action.test.ts +599 -0
- package/tests/tools/wait-for-feedback.test.ts +390 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands-impl.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for src/commands-impl.ts (the slash-command side-effect
|
|
5
|
+
* executor) and its helpers. These cover the wiring added in v0.5.0
|
|
6
|
+
* that makes `/plan add foo --title X` actually create an element on
|
|
7
|
+
* the canvas by routing through `bizar_plan_action` with a synthetic
|
|
8
|
+
* `ToolContext`.
|
|
9
|
+
*
|
|
10
|
+
* Groups:
|
|
11
|
+
* 1. buildSyntheticToolContext — populates all required fields
|
|
12
|
+
* 2. validateToolArgs — Zod pre-validation
|
|
13
|
+
* 3. executeSideEffect — create_plan (happy + existing slug)
|
|
14
|
+
* 4. executeSideEffect — list_plans (rich output)
|
|
15
|
+
* 5. executeSideEffect — open_plan_url (no I/O; uses parser response)
|
|
16
|
+
* 6. executeToolInvocation — happy path through real tool
|
|
17
|
+
* 7. executeToolInvocation — malformed args → validation response
|
|
18
|
+
* 8. executeToolInvocation — unknown tool → response error
|
|
19
|
+
* 9. executeToolInvocation — tool throws → stringified response
|
|
20
|
+
* 10. Synthetic context reaches the tool
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
|
|
24
|
+
import {
|
|
25
|
+
mkdtempSync,
|
|
26
|
+
rmSync,
|
|
27
|
+
existsSync,
|
|
28
|
+
readFileSync,
|
|
29
|
+
} from "node:fs";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { tmpdir } from "node:os";
|
|
32
|
+
import { z } from "zod";
|
|
33
|
+
import { tool, type ToolContext, type ToolDefinition } from "@opencode-ai/plugin";
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
executeSideEffect,
|
|
37
|
+
executeToolInvocation,
|
|
38
|
+
buildSyntheticToolContext,
|
|
39
|
+
validateToolArgs,
|
|
40
|
+
type ExecuteOptions,
|
|
41
|
+
type ExecutorContext,
|
|
42
|
+
} from "../src/commands-impl";
|
|
43
|
+
import type { SideEffect } from "../src/commands";
|
|
44
|
+
import {
|
|
45
|
+
createPlanActionTool,
|
|
46
|
+
} from "../src/tools/plan-action";
|
|
47
|
+
import { createBgGetCommentsTool } from "../src/tools/bg-get-comments";
|
|
48
|
+
import { createWaitForFeedbackTool } from "../src/tools/wait-for-feedback";
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Test doubles
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
class MockLogger {
|
|
55
|
+
messages: Array<{ level: string; message: string }> = [];
|
|
56
|
+
log(opts: { level: string; message: string }) { this.messages.push(opts); }
|
|
57
|
+
debug(m: string) { this.messages.push({ level: "debug", message: m }); }
|
|
58
|
+
info(m: string) { this.messages.push({ level: "info", message: m }); }
|
|
59
|
+
warn(m: string) { this.messages.push({ level: "warn", message: m }); }
|
|
60
|
+
error(m: string) { this.messages.push({ level: "error", message: m }); }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Per-suite temp worktree
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
let worktree: string;
|
|
68
|
+
const logger = new MockLogger();
|
|
69
|
+
|
|
70
|
+
beforeAll(() => {
|
|
71
|
+
worktree = mkdtempSync(join(tmpdir(), "bizar-commands-impl-test-"));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterAll(() => {
|
|
75
|
+
if (worktree) rmSync(worktree, { recursive: true, force: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function ctx(): ExecutorContext {
|
|
79
|
+
return { worktree, directory: worktree, logger };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function optsWith(tools: Record<string, ToolDefinition>): ExecuteOptions {
|
|
83
|
+
return {
|
|
84
|
+
tools,
|
|
85
|
+
defaultTemplate: "blank",
|
|
86
|
+
defaultPort: 4321,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const planTools = () => ({
|
|
91
|
+
bizar_plan_action: createPlanActionTool({ worktree, logger }),
|
|
92
|
+
bizar_get_plan_comments: createBgGetCommentsTool({ worktree, logger }),
|
|
93
|
+
bizar_wait_for_feedback: createWaitForFeedbackTool({ worktree, logger }),
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ===========================================================================
|
|
97
|
+
// Group 1 — buildSyntheticToolContext
|
|
98
|
+
// ===========================================================================
|
|
99
|
+
|
|
100
|
+
describe("buildSyntheticToolContext", () => {
|
|
101
|
+
test("populates all required fields (R6)", () => {
|
|
102
|
+
const c = ctx();
|
|
103
|
+
const tctx = buildSyntheticToolContext(c);
|
|
104
|
+
|
|
105
|
+
expect(tctx.sessionID).toBe("slash-command");
|
|
106
|
+
expect(tctx.messageID).toMatch(/^slash-command-\d+$/);
|
|
107
|
+
expect(tctx.agent).toBe("");
|
|
108
|
+
expect(tctx.directory).toBe(c.directory);
|
|
109
|
+
expect(tctx.worktree).toBe(c.worktree);
|
|
110
|
+
expect(tctx.abort).toBeInstanceOf(AbortSignal);
|
|
111
|
+
expect(typeof tctx.metadata).toBe("function");
|
|
112
|
+
expect(typeof tctx.ask).toBe("function");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("metadata() and ask() are no-ops (do not throw)", () => {
|
|
116
|
+
const tctx = buildSyntheticToolContext(ctx());
|
|
117
|
+
expect(() => tctx.metadata({ title: "t" })).not.toThrow();
|
|
118
|
+
expect(() => tctx.metadata({})).not.toThrow();
|
|
119
|
+
return expect(tctx.ask({ permission: "x", patterns: [], always: [], metadata: {} })).resolves.toBeUndefined();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("each call gets a fresh AbortSignal (not shared)", () => {
|
|
123
|
+
const a = buildSyntheticToolContext(ctx());
|
|
124
|
+
const b = buildSyntheticToolContext(ctx());
|
|
125
|
+
expect(a.abort).not.toBe(b.abort);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ===========================================================================
|
|
130
|
+
// Group 2 — validateToolArgs
|
|
131
|
+
// ===========================================================================
|
|
132
|
+
|
|
133
|
+
describe("validateToolArgs", () => {
|
|
134
|
+
const echoTool: ToolDefinition = tool({
|
|
135
|
+
description: "echo",
|
|
136
|
+
args: {
|
|
137
|
+
message: z.string(),
|
|
138
|
+
},
|
|
139
|
+
execute: async (a) => ({ output: JSON.stringify(a) }),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("ok on well-formed args", () => {
|
|
143
|
+
const r = validateToolArgs(echoTool, { message: "hi" });
|
|
144
|
+
expect(r.ok).toBe(true);
|
|
145
|
+
if (r.ok) expect(r.args.message).toBe("hi");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("error on malformed args (returns path + message)", () => {
|
|
149
|
+
const r = validateToolArgs(echoTool, { message: 42 });
|
|
150
|
+
expect(r.ok).toBe(false);
|
|
151
|
+
if (!r.ok) {
|
|
152
|
+
expect(r.error).toMatch(/message/);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("error on missing required arg", () => {
|
|
157
|
+
const r = validateToolArgs(echoTool, {});
|
|
158
|
+
expect(r.ok).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ===========================================================================
|
|
163
|
+
// Group 3 — executeSideEffect — create_plan
|
|
164
|
+
// ===========================================================================
|
|
165
|
+
|
|
166
|
+
describe("executeSideEffect — create_plan", () => {
|
|
167
|
+
test("happy path: writes plans/<slug>/meta.json and plan.json", async () => {
|
|
168
|
+
const slug = "create-happy";
|
|
169
|
+
const sideEffect: SideEffect = { kind: "create_plan", slug, template: null };
|
|
170
|
+
const res = await executeSideEffect(sideEffect, ctx(), optsWith(planTools()));
|
|
171
|
+
|
|
172
|
+
// No error — success is encoded in responseSuffix.
|
|
173
|
+
expect(res.responseOverride).toBeUndefined();
|
|
174
|
+
expect(res.responseSuffix).toMatch(/Created plans\/create-happy\//);
|
|
175
|
+
|
|
176
|
+
const dir = join(worktree, "plans", slug);
|
|
177
|
+
expect(existsSync(dir)).toBe(true);
|
|
178
|
+
expect(existsSync(join(dir, "meta.json"))).toBe(true);
|
|
179
|
+
expect(existsSync(join(dir, "plan.json"))).toBe(true);
|
|
180
|
+
|
|
181
|
+
const meta = JSON.parse(readFileSync(join(dir, "meta.json"), "utf-8"));
|
|
182
|
+
expect(meta.status).toBe("draft");
|
|
183
|
+
expect(typeof meta.lastEdited).toBe("string");
|
|
184
|
+
|
|
185
|
+
const canvas = JSON.parse(readFileSync(join(dir, "plan.json"), "utf-8"));
|
|
186
|
+
expect(canvas.schemaVersion).toBe(2);
|
|
187
|
+
expect(canvas.elements).toEqual([]);
|
|
188
|
+
expect(canvas.connections).toEqual([]);
|
|
189
|
+
expect(canvas.comments).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("refuses to clobber an existing plan (returns responseOverride)", async () => {
|
|
193
|
+
const slug = "create-existing";
|
|
194
|
+
const first: SideEffect = { kind: "create_plan", slug, template: null };
|
|
195
|
+
const r1 = await executeSideEffect(first, ctx(), optsWith(planTools()));
|
|
196
|
+
expect(r1.responseOverride).toBeUndefined(); // success path uses suffix
|
|
197
|
+
|
|
198
|
+
// Second create must NOT clobber.
|
|
199
|
+
const r2 = await executeSideEffect(first, ctx(), optsWith(planTools()));
|
|
200
|
+
expect(r2.responseOverride).toBeDefined();
|
|
201
|
+
expect(r2.responseOverride).toMatch(/already exists/);
|
|
202
|
+
expect(r2.responseOverride).toContain(slug);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("rejects invalid slug via plan-fs (delegates validation)", async () => {
|
|
206
|
+
const sideEffect: SideEffect = { kind: "create_plan", slug: "INVALID", template: null };
|
|
207
|
+
const r = await executeSideEffect(sideEffect, ctx(), optsWith(planTools()));
|
|
208
|
+
expect(r.responseOverride).toBeDefined();
|
|
209
|
+
expect(r.responseOverride).toMatch(/Invalid slug/);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ===========================================================================
|
|
214
|
+
// Group 4 — executeSideEffect — list_plans
|
|
215
|
+
// ===========================================================================
|
|
216
|
+
|
|
217
|
+
describe("executeSideEffect — list_plans", () => {
|
|
218
|
+
test("returns rich list with status + lastEdited", async () => {
|
|
219
|
+
// Pre-seed two plans.
|
|
220
|
+
await executeSideEffect(
|
|
221
|
+
{ kind: "create_plan", slug: "list-a", template: null },
|
|
222
|
+
ctx(),
|
|
223
|
+
optsWith(planTools()),
|
|
224
|
+
);
|
|
225
|
+
await executeSideEffect(
|
|
226
|
+
{ kind: "create_plan", slug: "list-b", template: null },
|
|
227
|
+
ctx(),
|
|
228
|
+
optsWith(planTools()),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const res = await executeSideEffect(
|
|
232
|
+
{ kind: "list_plans" },
|
|
233
|
+
ctx(),
|
|
234
|
+
optsWith(planTools()),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
expect(res.responseOverride).toBeDefined();
|
|
238
|
+
expect(res.responseOverride).toMatch(/list-a/);
|
|
239
|
+
expect(res.responseOverride).toMatch(/list-b/);
|
|
240
|
+
expect(res.responseOverride).toMatch(/status: draft/);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("returns 'No plans found' when worktree has no plans", async () => {
|
|
244
|
+
// Use a fresh worktree so we don't see the seeded ones above.
|
|
245
|
+
const tmp = mkdtempSync(join(tmpdir(), "bizar-empty-"));
|
|
246
|
+
const c: ExecutorContext = { worktree: tmp, directory: tmp, logger };
|
|
247
|
+
try {
|
|
248
|
+
const res = await executeSideEffect({ kind: "list_plans" }, c, optsWith(planTools()));
|
|
249
|
+
expect(res.responseOverride).toMatch(/No plans found/);
|
|
250
|
+
} finally {
|
|
251
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// ===========================================================================
|
|
257
|
+
// Group 5 — executeSideEffect — open_plan_url
|
|
258
|
+
// ===========================================================================
|
|
259
|
+
|
|
260
|
+
describe("executeSideEffect — open_plan_url", () => {
|
|
261
|
+
test("returns no override (the parser already built the URL)", async () => {
|
|
262
|
+
const res = await executeSideEffect(
|
|
263
|
+
{ kind: "open_plan_url", slug: "any-slug" },
|
|
264
|
+
ctx(),
|
|
265
|
+
optsWith(planTools()),
|
|
266
|
+
);
|
|
267
|
+
expect(res.responseOverride).toBeUndefined();
|
|
268
|
+
expect(res.responseSuffix).toBeUndefined();
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ===========================================================================
|
|
273
|
+
// Group 6 — executeToolInvocation — happy path
|
|
274
|
+
// ===========================================================================
|
|
275
|
+
|
|
276
|
+
describe("executeToolInvocation — happy path", () => {
|
|
277
|
+
test("invokes bizar_plan_action (get_canvas) with synthetic ToolContext", async () => {
|
|
278
|
+
// Pre-create a plan so get_canvas has something to return.
|
|
279
|
+
const slug = "inv-get-canvas";
|
|
280
|
+
await executeSideEffect(
|
|
281
|
+
{ kind: "create_plan", slug, template: null },
|
|
282
|
+
ctx(),
|
|
283
|
+
optsWith(planTools()),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// Spy by wrapping the real tool's execute.
|
|
287
|
+
const realPlanTool = planTools().bizar_plan_action;
|
|
288
|
+
let capturedCtx: ToolContext | null = null;
|
|
289
|
+
let capturedArgs: unknown = null;
|
|
290
|
+
const spy: ToolDefinition = {
|
|
291
|
+
description: realPlanTool.description,
|
|
292
|
+
args: realPlanTool.args,
|
|
293
|
+
execute: async (a, c) => {
|
|
294
|
+
capturedCtx = c;
|
|
295
|
+
capturedArgs = a;
|
|
296
|
+
return realPlanTool.execute(a as never, c);
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const tools: Record<string, ToolDefinition> = {
|
|
301
|
+
bizar_plan_action: spy,
|
|
302
|
+
bizar_get_plan_comments: planTools().bizar_get_plan_comments,
|
|
303
|
+
bizar_wait_for_feedback: planTools().bizar_wait_for_feedback,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const sideEffect: SideEffect = {
|
|
307
|
+
kind: "tool_invocation",
|
|
308
|
+
toolName: "bizar_plan_action",
|
|
309
|
+
args: { action: "get_canvas", planSlug: slug },
|
|
310
|
+
};
|
|
311
|
+
const res = await executeSideEffect(sideEffect, ctx(), optsWith(tools));
|
|
312
|
+
|
|
313
|
+
expect(res.responseOverride).toBeDefined();
|
|
314
|
+
// The tool returns a JSON-stringified canvas; verify it parses and
|
|
315
|
+
// contains our slug.
|
|
316
|
+
const parsed = JSON.parse(res.responseOverride!);
|
|
317
|
+
expect(parsed.ok).toBe(true);
|
|
318
|
+
expect(parsed.action).toBe("get_canvas");
|
|
319
|
+
expect(parsed.planSlug).toBe(slug);
|
|
320
|
+
expect(parsed.canvas.schemaVersion).toBe(2);
|
|
321
|
+
|
|
322
|
+
// The synthetic ToolContext reached the tool.
|
|
323
|
+
expect(capturedCtx).not.toBeNull();
|
|
324
|
+
expect(capturedCtx!.sessionID).toBe("slash-command");
|
|
325
|
+
expect(capturedCtx!.agent).toBe("");
|
|
326
|
+
expect(capturedCtx!.worktree).toBe(worktree);
|
|
327
|
+
expect(capturedCtx!.directory).toBe(worktree);
|
|
328
|
+
expect(capturedCtx!.abort).toBeInstanceOf(AbortSignal);
|
|
329
|
+
|
|
330
|
+
// Args were pre-validated (zod coerced/defaulted).
|
|
331
|
+
expect(capturedArgs).toMatchObject({ action: "get_canvas", planSlug: slug });
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// ===========================================================================
|
|
336
|
+
// Group 7 — executeToolInvocation — malformed args
|
|
337
|
+
// ===========================================================================
|
|
338
|
+
|
|
339
|
+
describe("executeToolInvocation — arg validation", () => {
|
|
340
|
+
test("rejects malformed args (missing planSlug) with a responseOverride", async () => {
|
|
341
|
+
const sideEffect: SideEffect = {
|
|
342
|
+
kind: "tool_invocation",
|
|
343
|
+
toolName: "bizar_plan_action",
|
|
344
|
+
args: { action: "get_canvas" /* missing planSlug */ },
|
|
345
|
+
};
|
|
346
|
+
const res = await executeSideEffect(sideEffect, ctx(), optsWith(planTools()));
|
|
347
|
+
expect(res.responseOverride).toBeDefined();
|
|
348
|
+
expect(res.responseOverride).toMatch(/Invalid arguments for bizar_plan_action/);
|
|
349
|
+
expect(res.responseOverride).toMatch(/planSlug/);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("rejects malformed args (invalid slug) with a responseOverride", async () => {
|
|
353
|
+
const sideEffect: SideEffect = {
|
|
354
|
+
kind: "tool_invocation",
|
|
355
|
+
toolName: "bizar_plan_action",
|
|
356
|
+
args: { action: "get_canvas", planSlug: "UPPERCASE" },
|
|
357
|
+
};
|
|
358
|
+
const res = await executeSideEffect(sideEffect, ctx(), optsWith(planTools()));
|
|
359
|
+
expect(res.responseOverride).toBeDefined();
|
|
360
|
+
expect(res.responseOverride).toMatch(/Invalid arguments for bizar_plan_action/);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ===========================================================================
|
|
365
|
+
// Group 8 — executeToolInvocation — unknown tool
|
|
366
|
+
// ===========================================================================
|
|
367
|
+
|
|
368
|
+
describe("executeToolInvocation — unknown tool", () => {
|
|
369
|
+
test("returns responseOverride listing available tools", async () => {
|
|
370
|
+
const sideEffect: SideEffect = {
|
|
371
|
+
kind: "tool_invocation",
|
|
372
|
+
toolName: "bizar_nonexistent",
|
|
373
|
+
args: {},
|
|
374
|
+
};
|
|
375
|
+
const res = await executeSideEffect(sideEffect, ctx(), optsWith(planTools()));
|
|
376
|
+
expect(res.responseOverride).toBeDefined();
|
|
377
|
+
expect(res.responseOverride).toMatch(/bizar_nonexistent.*not registered/);
|
|
378
|
+
// The error message should list the available tools.
|
|
379
|
+
expect(res.responseOverride).toMatch(/bizar_plan_action/);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("when no tools are registered, the error says (none)", async () => {
|
|
383
|
+
const sideEffect: SideEffect = {
|
|
384
|
+
kind: "tool_invocation",
|
|
385
|
+
toolName: "bizar_anything",
|
|
386
|
+
args: {},
|
|
387
|
+
};
|
|
388
|
+
const res = await executeSideEffect(sideEffect, ctx(), optsWith({}));
|
|
389
|
+
expect(res.responseOverride).toMatch(/\(none\)/);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// ===========================================================================
|
|
394
|
+
// Group 9 — executeToolInvocation — tool throws
|
|
395
|
+
// ===========================================================================
|
|
396
|
+
|
|
397
|
+
describe("executeToolInvocation — tool throws", () => {
|
|
398
|
+
test("stringifies the error into responseOverride (does NOT throw)", async () => {
|
|
399
|
+
const explodingTool: ToolDefinition = {
|
|
400
|
+
description: "explodes",
|
|
401
|
+
args: { x: z.string() },
|
|
402
|
+
execute: async () => {
|
|
403
|
+
throw new Error("boom!");
|
|
404
|
+
},
|
|
405
|
+
};
|
|
406
|
+
const sideEffect: SideEffect = {
|
|
407
|
+
kind: "tool_invocation",
|
|
408
|
+
toolName: "bizar_explode",
|
|
409
|
+
args: { x: "hi" },
|
|
410
|
+
};
|
|
411
|
+
const tools: Record<string, ToolDefinition> = {
|
|
412
|
+
bizar_explode: explodingTool,
|
|
413
|
+
};
|
|
414
|
+
// Must not throw.
|
|
415
|
+
const res = await executeSideEffect(sideEffect, ctx(), optsWith(tools));
|
|
416
|
+
expect(res.responseOverride).toMatch(/bizar_explode failed: boom!/);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// ===========================================================================
|
|
421
|
+
// Group 10 — synthetic context field-by-field
|
|
422
|
+
// ===========================================================================
|
|
423
|
+
|
|
424
|
+
describe("synthetic ToolContext — exhaustive field check", () => {
|
|
425
|
+
test("exposes every field the tool contract requires", () => {
|
|
426
|
+
const c = ctx();
|
|
427
|
+
const tctx = buildSyntheticToolContext(c);
|
|
428
|
+
const keys: Array<keyof ToolContext> = [
|
|
429
|
+
"sessionID",
|
|
430
|
+
"messageID",
|
|
431
|
+
"agent",
|
|
432
|
+
"directory",
|
|
433
|
+
"worktree",
|
|
434
|
+
"abort",
|
|
435
|
+
"metadata",
|
|
436
|
+
"ask",
|
|
437
|
+
];
|
|
438
|
+
for (const k of keys) {
|
|
439
|
+
expect(tctx[k]).toBeDefined();
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
});
|