@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +448 -0
  3. package/bun.lock +88 -0
  4. package/index.ts +1113 -0
  5. package/package.json +42 -0
  6. package/scripts/check-forbidden-imports.sh +33 -0
  7. package/src/background-state.ts +463 -0
  8. package/src/background.ts +964 -0
  9. package/src/commands-impl.ts +369 -0
  10. package/src/commands.ts +880 -0
  11. package/src/event-stream.ts +574 -0
  12. package/src/fingerprint.ts +120 -0
  13. package/src/handoff.ts +79 -0
  14. package/src/http-client.ts +467 -0
  15. package/src/logger.ts +144 -0
  16. package/src/loop.ts +176 -0
  17. package/src/options.ts +421 -0
  18. package/src/plan-fs.ts +323 -0
  19. package/src/report.ts +178 -0
  20. package/src/research-prompt.ts +35 -0
  21. package/src/serve.ts +476 -0
  22. package/src/settings.ts +349 -0
  23. package/src/state.ts +298 -0
  24. package/src/tools/bg-collect.ts +104 -0
  25. package/src/tools/bg-get-comments.ts +239 -0
  26. package/src/tools/bg-kill.ts +87 -0
  27. package/src/tools/bg-spawn.ts +263 -0
  28. package/src/tools/bg-status.ts +99 -0
  29. package/src/tools/plan-action.ts +767 -0
  30. package/src/tools/wait-for-feedback.ts +402 -0
  31. package/tests/attach-handler-bug.test.ts +166 -0
  32. package/tests/background-state.test.ts +277 -0
  33. package/tests/background.test.ts +402 -0
  34. package/tests/block.test.ts +193 -0
  35. package/tests/canonical-key-order.test.ts +71 -0
  36. package/tests/commands-impl.test.ts +442 -0
  37. package/tests/commands.test.ts +548 -0
  38. package/tests/config.test.ts +122 -0
  39. package/tests/dispose.test.ts +336 -0
  40. package/tests/event-stream.test.ts +409 -0
  41. package/tests/event.test.ts +262 -0
  42. package/tests/fingerprint.test.ts +161 -0
  43. package/tests/http-client.test.ts +403 -0
  44. package/tests/init-helpers.test.ts +203 -0
  45. package/tests/integration/slash-command.test.ts +348 -0
  46. package/tests/integration/tool-routing.test.ts +314 -0
  47. package/tests/loop.test.ts +397 -0
  48. package/tests/options.test.ts +274 -0
  49. package/tests/serve.test.ts +335 -0
  50. package/tests/settings.test.ts +351 -0
  51. package/tests/stall-think.test.ts +749 -0
  52. package/tests/state.test.ts +275 -0
  53. package/tests/tools/bg-collect.test.ts +337 -0
  54. package/tests/tools/bg-get-comments.test.ts +485 -0
  55. package/tests/tools/bg-kill.test.ts +231 -0
  56. package/tests/tools/bg-spawn.test.ts +311 -0
  57. package/tests/tools/bg-status.test.ts +216 -0
  58. package/tests/tools/plan-action.test.ts +599 -0
  59. package/tests/tools/wait-for-feedback.test.ts +390 -0
  60. 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
+ });