@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,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-routing.test.ts — Integration tests for the tool_invocation path
|
|
3
|
+
* in slash commands.
|
|
4
|
+
*
|
|
5
|
+
* Exercises:
|
|
6
|
+
* - Synthetic ToolContext has all required fields (sessionID, messageID,
|
|
7
|
+
* agent, directory, worktree, abort, metadata, ask)
|
|
8
|
+
* - Args validation via Zod rejects malformed args → refusal response
|
|
9
|
+
* - Real `bizar_plan_action` factory is invoked when args are valid
|
|
10
|
+
* - Response string contains the tool's JSON output
|
|
11
|
+
* - `metadata()` and `ask()` stubs are callable and no-op
|
|
12
|
+
* - `abort.signal` is a valid AbortSignal (not aborted)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test";
|
|
16
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
executeSideEffect,
|
|
22
|
+
buildSyntheticToolContext,
|
|
23
|
+
type ExecutorContext,
|
|
24
|
+
type ExecuteOptions,
|
|
25
|
+
} from "../../src/commands-impl";
|
|
26
|
+
import { createPlanActionTool } from "../../src/tools/plan-action";
|
|
27
|
+
import { createBgGetCommentsTool } from "../../src/tools/bg-get-comments";
|
|
28
|
+
import { createWaitForFeedbackTool } from "../../src/tools/wait-for-feedback";
|
|
29
|
+
import type { SideEffect } from "../../src/commands";
|
|
30
|
+
import type { ToolContext } from "@opencode-ai/plugin";
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Mock logger
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
class MockLogger {
|
|
37
|
+
messages: Array<{ level: string; message: string }> = [];
|
|
38
|
+
log(opts: { level: string; message: string }) { this.messages.push(opts); }
|
|
39
|
+
debug(m: string) { this.messages.push({ level: "debug", message: m }); }
|
|
40
|
+
info(m: string) { this.messages.push({ level: "info", message: m }); }
|
|
41
|
+
warn(m: string) { this.messages.push({ level: "warn", message: m }); }
|
|
42
|
+
error(m: string) { this.messages.push({ level: "error", message: m }); }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Per-test temp worktree
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
let worktree: string;
|
|
50
|
+
let logger: MockLogger;
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
worktree = mkdtempSync(join(tmpdir(), "bizar-tool-routing-"));
|
|
54
|
+
logger = new MockLogger();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
if (worktree) rmSync(worktree, { recursive: true, force: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterAll(() => {
|
|
62
|
+
if (worktree) rmSync(worktree, { recursive: true, force: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
function makeCtx(): ExecutorContext {
|
|
66
|
+
return { worktree, directory: worktree, logger };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function makeOpts(toolsOverride?: Record<string, ReturnType<typeof createPlanActionTool>>): ExecuteOptions {
|
|
70
|
+
return {
|
|
71
|
+
tools: {
|
|
72
|
+
bizar_plan_action: createPlanActionTool({ worktree, logger }),
|
|
73
|
+
bizar_get_plan_comments: createBgGetCommentsTool({ worktree, logger }),
|
|
74
|
+
bizar_wait_for_feedback: createWaitForFeedbackTool({ worktree, logger }),
|
|
75
|
+
...toolsOverride,
|
|
76
|
+
},
|
|
77
|
+
defaultTemplate: "blank",
|
|
78
|
+
defaultPort: 4321,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Test cases
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
describe("ToolContext — required fields", () => {
|
|
87
|
+
test("synthetic ToolContext has all required fields (R6)", () => {
|
|
88
|
+
const ctx = makeCtx();
|
|
89
|
+
const tctx = buildSyntheticToolContext(ctx);
|
|
90
|
+
|
|
91
|
+
expect(tctx.sessionID).toBe("slash-command");
|
|
92
|
+
expect(tctx.messageID).toMatch(/^slash-command-\d+$/);
|
|
93
|
+
expect(tctx.agent).toBe("");
|
|
94
|
+
expect(tctx.directory).toBe(ctx.directory);
|
|
95
|
+
expect(tctx.worktree).toBe(ctx.worktree);
|
|
96
|
+
expect(tctx.abort).toBeInstanceOf(AbortSignal);
|
|
97
|
+
expect(typeof tctx.metadata).toBe("function");
|
|
98
|
+
expect(typeof tctx.ask).toBe("function");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("abort.signal is NOT aborted", () => {
|
|
102
|
+
const tctx = buildSyntheticToolContext(makeCtx());
|
|
103
|
+
expect(tctx.abort.aborted).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("metadata() is a no-op (does not throw)", () => {
|
|
107
|
+
const tctx = buildSyntheticToolContext(makeCtx());
|
|
108
|
+
expect(() => tctx.metadata({ title: "test" })).not.toThrow();
|
|
109
|
+
expect(() => tctx.metadata({})).not.toThrow();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("ask() is a no-op (resolves to undefined)", async () => {
|
|
113
|
+
const tctx = buildSyntheticToolContext(makeCtx());
|
|
114
|
+
await expect(
|
|
115
|
+
tctx.ask({ permission: "x", patterns: [], always: [], metadata: {} }),
|
|
116
|
+
).resolves.toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("each call gets a fresh AbortSignal (not shared)", () => {
|
|
120
|
+
const a = buildSyntheticToolContext(makeCtx());
|
|
121
|
+
const b = buildSyntheticToolContext(makeCtx());
|
|
122
|
+
expect(a.abort).not.toBe(b.abort);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("ToolContext — exhaustive field check", () => {
|
|
127
|
+
test("exposes every field the tool contract requires", () => {
|
|
128
|
+
const c = makeCtx();
|
|
129
|
+
const tctx = buildSyntheticToolContext(c);
|
|
130
|
+
const keys: Array<keyof ToolContext> = [
|
|
131
|
+
"sessionID",
|
|
132
|
+
"messageID",
|
|
133
|
+
"agent",
|
|
134
|
+
"directory",
|
|
135
|
+
"worktree",
|
|
136
|
+
"abort",
|
|
137
|
+
"metadata",
|
|
138
|
+
"ask",
|
|
139
|
+
];
|
|
140
|
+
for (const k of keys) {
|
|
141
|
+
expect(tctx[k]).toBeDefined();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("ToolInvocation — args validation via Zod", () => {
|
|
147
|
+
test("malformed args (invalid slug) → refusal response, tool NOT invoked", async () => {
|
|
148
|
+
// Pre-create the plan so the tool WOULD succeed with valid args
|
|
149
|
+
await executeSideEffect(
|
|
150
|
+
{ kind: "create_plan", slug: "test-plan", template: null },
|
|
151
|
+
makeCtx(),
|
|
152
|
+
makeOpts(),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Spy on the tool
|
|
156
|
+
let invokeCount = 0;
|
|
157
|
+
const realTool = makeOpts().tools["bizar_plan_action"]!;
|
|
158
|
+
const spyOpts: ExecuteOptions = {
|
|
159
|
+
...makeOpts(),
|
|
160
|
+
tools: {
|
|
161
|
+
...makeOpts().tools,
|
|
162
|
+
bizar_plan_action: {
|
|
163
|
+
description: realTool.description,
|
|
164
|
+
args: realTool.args,
|
|
165
|
+
execute: async (args, ctx) => {
|
|
166
|
+
invokeCount++;
|
|
167
|
+
return realTool.execute(args as never, ctx);
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const sideEffect: SideEffect = {
|
|
174
|
+
kind: "tool_invocation",
|
|
175
|
+
toolName: "bizar_plan_action",
|
|
176
|
+
args: { action: "get_canvas", planSlug: "INVALID_SLUG_UPPER" },
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const r = await executeSideEffect(sideEffect, makeCtx(), spyOpts);
|
|
180
|
+
|
|
181
|
+
// Should have returned a refusal (validation failed before tool call)
|
|
182
|
+
expect(r.responseOverride).toMatch(/Invalid arguments/);
|
|
183
|
+
expect(r.responseOverride).toMatch(/planSlug/);
|
|
184
|
+
// Tool should NOT have been called (validation short-circuits)
|
|
185
|
+
expect(invokeCount).toBe(0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("missing required arg (no planSlug) → refusal response, tool NOT invoked", async () => {
|
|
189
|
+
let invokeCount = 0;
|
|
190
|
+
const realTool = makeOpts().tools["bizar_plan_action"]!;
|
|
191
|
+
const spyOpts: ExecuteOptions = {
|
|
192
|
+
...makeOpts(),
|
|
193
|
+
tools: {
|
|
194
|
+
...makeOpts().tools,
|
|
195
|
+
bizar_plan_action: {
|
|
196
|
+
description: realTool.description,
|
|
197
|
+
args: realTool.args,
|
|
198
|
+
execute: async (args, ctx) => {
|
|
199
|
+
invokeCount++;
|
|
200
|
+
return realTool.execute(args as never, ctx);
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const sideEffect: SideEffect = {
|
|
207
|
+
kind: "tool_invocation",
|
|
208
|
+
toolName: "bizar_plan_action",
|
|
209
|
+
args: { action: "get_canvas" /* missing planSlug */ },
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const r = await executeSideEffect(sideEffect, makeCtx(), spyOpts);
|
|
213
|
+
|
|
214
|
+
expect(r.responseOverride).toMatch(/Invalid arguments/);
|
|
215
|
+
expect(invokeCount).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("valid args → tool IS invoked (spy verifies factory call)", async () => {
|
|
219
|
+
// Pre-create plan
|
|
220
|
+
await executeSideEffect(
|
|
221
|
+
{ kind: "create_plan", slug: "valid-test", template: null },
|
|
222
|
+
makeCtx(),
|
|
223
|
+
makeOpts(),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
let invokeCount = 0;
|
|
227
|
+
let capturedCtx: ToolContext | null = null;
|
|
228
|
+
const realTool = makeOpts().tools["bizar_plan_action"]!;
|
|
229
|
+
const spyOpts: ExecuteOptions = {
|
|
230
|
+
...makeOpts(),
|
|
231
|
+
tools: {
|
|
232
|
+
...makeOpts().tools,
|
|
233
|
+
bizar_plan_action: {
|
|
234
|
+
description: realTool.description,
|
|
235
|
+
args: realTool.args,
|
|
236
|
+
execute: async (args, ctx) => {
|
|
237
|
+
invokeCount++;
|
|
238
|
+
capturedCtx = ctx;
|
|
239
|
+
return realTool.execute(args as never, ctx);
|
|
240
|
+
},
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const sideEffect: SideEffect = {
|
|
246
|
+
kind: "tool_invocation",
|
|
247
|
+
toolName: "bizar_plan_action",
|
|
248
|
+
args: { action: "get_canvas", planSlug: "valid-test" },
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const r = await executeSideEffect(sideEffect, makeCtx(), spyOpts);
|
|
252
|
+
|
|
253
|
+
expect(invokeCount).toBe(1);
|
|
254
|
+
expect(r.responseOverride).toBeDefined();
|
|
255
|
+
// The response should be JSON containing our slug
|
|
256
|
+
expect(r.responseOverride!).toMatch(/valid-test/);
|
|
257
|
+
// Captured context should be the synthetic one
|
|
258
|
+
expect(capturedCtx).not.toBeNull();
|
|
259
|
+
expect(capturedCtx!.sessionID).toBe("slash-command");
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("ToolInvocation — response format", () => {
|
|
264
|
+
test("response string contains the tool's JSON output", async () => {
|
|
265
|
+
// Pre-create plan with known content
|
|
266
|
+
await executeSideEffect(
|
|
267
|
+
{ kind: "create_plan", slug: "resp-test", template: null },
|
|
268
|
+
makeCtx(),
|
|
269
|
+
makeOpts(),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const sideEffect: SideEffect = {
|
|
273
|
+
kind: "tool_invocation",
|
|
274
|
+
toolName: "bizar_plan_action",
|
|
275
|
+
args: { action: "get_canvas", planSlug: "resp-test" },
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const r = await executeSideEffect(sideEffect, makeCtx(), makeOpts());
|
|
279
|
+
|
|
280
|
+
expect(r.responseOverride).toBeDefined();
|
|
281
|
+
// Should be parseable JSON with our fields
|
|
282
|
+
const parsed = JSON.parse(r.responseOverride!);
|
|
283
|
+
expect(parsed.ok).toBe(true);
|
|
284
|
+
expect(parsed.planSlug).toBe("resp-test");
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("ToolInvocation — unknown tool", () => {
|
|
289
|
+
test("unknown tool name → refusal response listing available tools", async () => {
|
|
290
|
+
const sideEffect: SideEffect = {
|
|
291
|
+
kind: "tool_invocation",
|
|
292
|
+
toolName: "bizar_nonexistent",
|
|
293
|
+
args: {},
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const r = await executeSideEffect(sideEffect, makeCtx(), makeOpts());
|
|
297
|
+
|
|
298
|
+
expect(r.responseOverride).toBeDefined();
|
|
299
|
+
expect(r.responseOverride).toMatch(/bizar_nonexistent/);
|
|
300
|
+
expect(r.responseOverride).toMatch(/bizar_plan_action/);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("when no tools registered, error says \(none\)", async () => {
|
|
304
|
+
const sideEffect: SideEffect = {
|
|
305
|
+
kind: "tool_invocation",
|
|
306
|
+
toolName: "bizar_anything",
|
|
307
|
+
args: {},
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const r = await executeSideEffect(sideEffect, makeCtx(), { tools: {}, defaultTemplate: "blank", defaultPort: 4321 });
|
|
311
|
+
|
|
312
|
+
expect(r.responseOverride).toMatch(/\(none\)/);
|
|
313
|
+
});
|
|
314
|
+
});
|