@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,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
+ });