@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,203 @@
1
+ /**
2
+ * Init-helpers regression tests (v0.5.2).
3
+ *
4
+ * Covers the postmortem-2026-06-18 Layer 1 fix: `client.session.list()`
5
+ * is now raced against a 1-second timeout. Without this, a slow or
6
+ * broken session store would hang the plugin's `init()` forever and
7
+ * stall the UI on a blank screen.
8
+ *
9
+ * Two layers of test:
10
+ * 1) `withTimeout` — pure unit tests on the timeout helper itself.
11
+ * 2) `readValidSessionIds` — integration-style test that passes a
12
+ * fake `PluginInput` whose `client.session.list()` never resolves
13
+ * and verifies that the function returns within ~1.1s.
14
+ *
15
+ * The second test is what would have caught the original bug: the
16
+ * function must NOT block forever on a slow session store.
17
+ */
18
+
19
+ import { describe, test, expect } from "bun:test";
20
+
21
+ import {
22
+ withTimeout,
23
+ readValidSessionIds,
24
+ } from "../index.js";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // `withTimeout` — pure unit tests
28
+ // ---------------------------------------------------------------------------
29
+
30
+ describe("withTimeout", () => {
31
+ test("resolves with the promise's value when it completes in time", async () => {
32
+ const result = await withTimeout(
33
+ Promise.resolve("ok"),
34
+ 1000,
35
+ "test",
36
+ );
37
+ expect(result).toBe("ok");
38
+ });
39
+
40
+ test("rejects with a labeled error when the promise takes too long", async () => {
41
+ const hanging = new Promise<string>(() => {
42
+ // never resolves
43
+ });
44
+ const start = Date.now();
45
+ let caught: Error | null = null;
46
+ try {
47
+ await withTimeout(hanging, 100, "hanging-promise");
48
+ } catch (err) {
49
+ caught = err as Error;
50
+ }
51
+ const elapsed = Date.now() - start;
52
+ expect(caught).not.toBeNull();
53
+ expect(caught?.message).toContain("hanging-promise");
54
+ expect(caught?.message).toContain("100ms");
55
+ // Should fire within ~100ms (allow 50ms of slack for event-loop jitter).
56
+ expect(elapsed).toBeGreaterThanOrEqual(95);
57
+ expect(elapsed).toBeLessThan(500);
58
+ });
59
+
60
+ test("propagates rejections from the underlying promise", async () => {
61
+ const failing = Promise.reject(new Error("underlying failure"));
62
+ let caught: Error | null = null;
63
+ try {
64
+ await withTimeout(failing, 1000, "test");
65
+ } catch (err) {
66
+ caught = err as Error;
67
+ }
68
+ expect(caught).not.toBeNull();
69
+ expect(caught?.message).toBe("underlying failure");
70
+ });
71
+
72
+ test("clears the timer on success (no late rejection)", async () => {
73
+ // Resolve after 50ms, well within the 1000ms timeout. The timer
74
+ // should be cleared; we can't directly observe the cleanup, but
75
+ // the test verifies the function returns the value without ever
76
+ // throwing (the cleared timer's callback never fires).
77
+ const result = await withTimeout(
78
+ new Promise((resolve) => setTimeout(() => resolve("late"), 50)),
79
+ 1000,
80
+ "test",
81
+ );
82
+ expect(result).toBe("late");
83
+ // Wait long enough that a leaked timer would have fired.
84
+ await new Promise((r) => setTimeout(r, 100));
85
+ });
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // `readValidSessionIds` — the postmortem Layer 1 fix
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Minimal `PluginInput` shape for testing `readValidSessionIds`. We
94
+ * only need `client`; the other fields are never read by the
95
+ * function under test.
96
+ */
97
+ function makeInput(client: unknown): { client: unknown } {
98
+ return { client } as never;
99
+ }
100
+
101
+ describe("readValidSessionIds — postmortem Layer 1 fix (v0.5.2)", () => {
102
+ test("returns the session IDs when client.session.list() resolves", async () => {
103
+ const input = makeInput({
104
+ session: {
105
+ list: async () => ({
106
+ data: [
107
+ { id: "ses_a" },
108
+ { id: "ses_b" },
109
+ ],
110
+ }),
111
+ },
112
+ });
113
+ const ids = await readValidSessionIds(input as never);
114
+ expect(ids).toBeInstanceOf(Set);
115
+ expect([...ids].sort()).toEqual(["ses_a", "ses_b"]);
116
+ });
117
+
118
+ test("returns the session IDs when client.session.list() returns an array directly", async () => {
119
+ const input = makeInput({
120
+ session: {
121
+ list: async () => [{ id: "ses_x" }, { id: "ses_y" }],
122
+ },
123
+ });
124
+ const ids = await readValidSessionIds(input as never);
125
+ expect([...ids].sort()).toEqual(["ses_x", "ses_y"]);
126
+ });
127
+
128
+ test("returns empty set when client.session is missing", async () => {
129
+ const input = makeInput({});
130
+ const ids = await readValidSessionIds(input as never);
131
+ expect(ids.size).toBe(0);
132
+ });
133
+
134
+ test("returns empty set when client.session.list is not a function", async () => {
135
+ const input = makeInput({ session: { list: "not-a-function" } });
136
+ const ids = await readValidSessionIds(input as never);
137
+ expect(ids.size).toBe(0);
138
+ });
139
+
140
+ test("returns empty set when client.session.list() rejects", async () => {
141
+ const input = makeInput({
142
+ session: {
143
+ list: async () => {
144
+ throw new Error("session store down");
145
+ },
146
+ },
147
+ });
148
+ const ids = await readValidSessionIds(input as never);
149
+ expect(ids.size).toBe(0);
150
+ });
151
+
152
+ test(
153
+ "REGRESSION: returns within 1.5s when client.session.list() hangs forever (postmortem Layer 1)",
154
+ async () => {
155
+ // This is the actual bug from the 2026-06-18 postmortem. Before
156
+ // the fix, the call would hang forever and the plugin's `init()`
157
+ // would never resolve.
158
+ const input = makeInput({
159
+ session: {
160
+ list: () => new Promise(() => {
161
+ // never resolves — simulates a stuck session store
162
+ }),
163
+ },
164
+ });
165
+ const start = Date.now();
166
+ const ids = await readValidSessionIds(input as never);
167
+ const elapsed = Date.now() - start;
168
+ // The function should return within ~1s (the timeout). We allow
169
+ // 500ms of slack for event-loop jitter.
170
+ expect(elapsed).toBeLessThan(1500);
171
+ // The fallback is an empty set — the age-based cleanup still
172
+ // runs (spec §4.6).
173
+ expect(ids.size).toBe(0);
174
+ },
175
+ );
176
+
177
+ test(
178
+ "REGRESSION: late rejection from hanging list() does not crash the process",
179
+ async () => {
180
+ // After the 1s timeout fires, the original `list()` call may
181
+ // eventually reject (or it may never). The function attaches a
182
+ // no-op `.catch(() => undefined)` to suppress the unhandled-
183
+ // rejection. We can't directly observe unhandled rejections in
184
+ // bun:test, but we can verify the function returns cleanly.
185
+ let rejectFn: ((err: Error) => void) | null = null;
186
+ const input = makeInput({
187
+ session: {
188
+ list: () => new Promise((_, reject) => {
189
+ rejectFn = reject;
190
+ }),
191
+ },
192
+ });
193
+ const ids = await readValidSessionIds(input as never);
194
+ expect(ids.size).toBe(0);
195
+ // Now reject the hanging promise AFTER the function returned.
196
+ // If the no-op catch is missing, this would be an unhandled
197
+ // rejection. With it, the process keeps running.
198
+ if (rejectFn) rejectFn(new Error("late rejection"));
199
+ // Give the microtask queue a chance to run.
200
+ await new Promise((r) => setTimeout(r, 10));
201
+ },
202
+ );
203
+ });
@@ -0,0 +1,348 @@
1
+ /**
2
+ * slash-command.test.ts — Integration tests for the full slash-command
3
+ * hook → parser → side-effect → file-I/O path.
4
+ *
5
+ * Exercises:
6
+ * - `/plan new foo` → `plans/foo/meta.json` + `plans/foo/plan.json` on disk
7
+ * - `/plan list` → response contains slugs of seeded plans
8
+ * - `/plan add foo --title X --type task` → element written to `plan.json`
9
+ * - `/plan status foo approved` → `meta.json` status updated
10
+ * - `/plan delete foo <elementId>` → element removed from `plan.json`
11
+ * - `/plan wait foo` → deferred response (< 100 ms, no blocking)
12
+ * - `/plan new foo` on existing slug → refusal, no clobber
13
+ * - `/plan comment foo "text"` → comment written to `plan.json`
14
+ * - `/plan comments foo` → response lists the comment
15
+ */
16
+
17
+ import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test";
18
+ import {
19
+ mkdtempSync,
20
+ rmSync,
21
+ existsSync,
22
+ readFileSync,
23
+ writeFileSync,
24
+ mkdirSync,
25
+ } from "node:fs";
26
+ import { join } from "node:path";
27
+ import { tmpdir } from "node:os";
28
+
29
+ import { parseSlashCommand } from "../../src/commands";
30
+ import {
31
+ executeSideEffect,
32
+ type ExecutorContext,
33
+ type ExecuteOptions,
34
+ } from "../../src/commands-impl";
35
+ import { createPlanActionTool } from "../../src/tools/plan-action";
36
+ import { createBgGetCommentsTool } from "../../src/tools/bg-get-comments";
37
+ import { createWaitForFeedbackTool } from "../../src/tools/wait-for-feedback";
38
+ import { DEFAULT_PLAN_SETTINGS, type PlanSettings } from "../../src/settings";
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Mock logger
42
+ // ---------------------------------------------------------------------------
43
+
44
+ class MockLogger {
45
+ messages: Array<{ level: string; message: string }> = [];
46
+ log(opts: { level: string; message: string }) { this.messages.push(opts); }
47
+ debug(m: string) { this.messages.push({ level: "debug", message: m }); }
48
+ info(m: string) { this.messages.push({ level: "info", message: m }); }
49
+ warn(m: string) { this.messages.push({ level: "warn", message: m }); }
50
+ error(m: string) { this.messages.push({ level: "error", message: m }); }
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Per-test temp worktree
55
+ // ---------------------------------------------------------------------------
56
+
57
+ let worktree: string;
58
+ let logger: MockLogger;
59
+
60
+ beforeEach(() => {
61
+ worktree = mkdtempSync(join(tmpdir(), "bizar-integration-"));
62
+ logger = new MockLogger();
63
+ });
64
+
65
+ afterEach(() => {
66
+ if (worktree) rmSync(worktree, { recursive: true, force: true });
67
+ });
68
+
69
+ afterAll(() => {
70
+ // already cleaned in afterEach, but keep for safety
71
+ if (worktree) rmSync(worktree, { recursive: true, force: true });
72
+ });
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Helper: build minimal ExecutorContext + ExecuteOptions
76
+ // ---------------------------------------------------------------------------
77
+
78
+ function makeCtx(): ExecutorContext {
79
+ return { worktree, directory: worktree, logger };
80
+ }
81
+
82
+ function makeOpts(): ExecuteOptions {
83
+ return {
84
+ tools: {
85
+ bizar_plan_action: createPlanActionTool({ worktree, logger }),
86
+ bizar_get_plan_comments: createBgGetCommentsTool({ worktree, logger }),
87
+ bizar_wait_for_feedback: createWaitForFeedbackTool({ worktree, logger }),
88
+ },
89
+ defaultTemplate: "blank",
90
+ defaultPort: 4321,
91
+ };
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Test cases
96
+ // ---------------------------------------------------------------------------
97
+
98
+ describe("integration: /plan new", () => {
99
+ test("/plan new foo → creates plans/foo/meta.json and plan.json with correct content", async () => {
100
+ const result = parseSlashCommand("/plan new foo", {
101
+ currentSettings: DEFAULT_PLAN_SETTINGS,
102
+ availablePlanSlugs: [],
103
+ defaultPort: 4321,
104
+ });
105
+ expect(result).not.toBeNull();
106
+ expect(result!.sideEffect!.kind).toBe("create_plan");
107
+
108
+ await executeSideEffect(result!.sideEffect!, makeCtx(), makeOpts());
109
+
110
+ const metaPath = join(worktree, "plans", "foo", "meta.json");
111
+ const planPath = join(worktree, "plans", "foo", "plan.json");
112
+ expect(existsSync(metaPath)).toBe(true);
113
+ expect(existsSync(planPath)).toBe(true);
114
+
115
+ const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
116
+ expect(meta.status).toBe("draft");
117
+ expect(meta.slug).toBeUndefined(); // slug not stored, title is titleCase
118
+ expect(typeof meta.lastEdited).toBe("string");
119
+
120
+ const canvas = JSON.parse(readFileSync(planPath, "utf-8"));
121
+ expect(canvas.schemaVersion).toBe(2);
122
+ expect(canvas.elements).toEqual([]);
123
+ expect(canvas.connections).toEqual([]);
124
+ expect(canvas.comments).toEqual([]);
125
+ });
126
+ });
127
+
128
+ describe("integration: /plan list", () => {
129
+ test("/plan list after creating foo and bar → response contains both slugs", async () => {
130
+ // Seed two plans
131
+ for (const slug of ["foo", "bar"]) {
132
+ const r = parseSlashCommand(`/plan new ${slug}`, {
133
+ currentSettings: DEFAULT_PLAN_SETTINGS,
134
+ availablePlanSlugs: [],
135
+ defaultPort: 4321,
136
+ });
137
+ await executeSideEffect(r!.sideEffect!, makeCtx(), makeOpts());
138
+ }
139
+
140
+ const listResult = parseSlashCommand("/plan list", {
141
+ currentSettings: DEFAULT_PLAN_SETTINGS,
142
+ availablePlanSlugs: ["foo", "bar"],
143
+ defaultPort: 4321,
144
+ });
145
+ expect(listResult).not.toBeNull();
146
+ const execResult = await executeSideEffect(listResult!.sideEffect!, makeCtx(), makeOpts());
147
+ const response = execResult.responseOverride ?? listResult!.response;
148
+ expect(response).toMatch(/foo/);
149
+ expect(response).toMatch(/bar/);
150
+ });
151
+ });
152
+
153
+ describe("integration: /plan add", () => {
154
+ test("/plan add foo --title X --type task → plan.json contains the element", async () => {
155
+ // First create the plan
156
+ const newR = parseSlashCommand("/plan new foo", {
157
+ currentSettings: DEFAULT_PLAN_SETTINGS,
158
+ availablePlanSlugs: [],
159
+ defaultPort: 4321,
160
+ });
161
+ await executeSideEffect(newR!.sideEffect!, makeCtx(), makeOpts());
162
+
163
+ // Now add an element
164
+ const addR = parseSlashCommand('/plan add foo --title X --type task', {
165
+ currentSettings: DEFAULT_PLAN_SETTINGS,
166
+ availablePlanSlugs: ["foo"],
167
+ defaultPort: 4321,
168
+ });
169
+ expect(addR).not.toBeNull();
170
+ expect(addR!.sideEffect!.kind).toBe("tool_invocation");
171
+ const execR = await executeSideEffect(addR!.sideEffect!, makeCtx(), makeOpts());
172
+
173
+ const planPath = join(worktree, "plans", "foo", "plan.json");
174
+ const canvas = JSON.parse(readFileSync(planPath, "utf-8"));
175
+ expect(canvas.elements).toHaveLength(1);
176
+ expect(canvas.elements[0]!.title).toBe("X");
177
+ expect(canvas.elements[0]!.type).toBe("task");
178
+ });
179
+ });
180
+
181
+ describe("integration: /plan status", () => {
182
+ test("/plan status foo approved → meta.json status is approved", async () => {
183
+ // Create the plan first
184
+ const newR = parseSlashCommand("/plan new foo", {
185
+ currentSettings: DEFAULT_PLAN_SETTINGS,
186
+ availablePlanSlugs: [],
187
+ defaultPort: 4321,
188
+ });
189
+ await executeSideEffect(newR!.sideEffect!, makeCtx(), makeOpts());
190
+
191
+ const statusR = parseSlashCommand("/plan status foo approved", {
192
+ currentSettings: DEFAULT_PLAN_SETTINGS,
193
+ availablePlanSlugs: ["foo"],
194
+ defaultPort: 4321,
195
+ });
196
+ await executeSideEffect(statusR!.sideEffect!, makeCtx(), makeOpts());
197
+
198
+ const metaPath = join(worktree, "plans", "foo", "meta.json");
199
+ const meta = JSON.parse(readFileSync(metaPath, "utf-8"));
200
+ expect(meta.status).toBe("approved");
201
+ });
202
+ });
203
+
204
+ describe("integration: /plan delete", () => {
205
+ test("/plan delete foo <elementId> → element removed from plan.json", async () => {
206
+ // Create plan and add an element
207
+ const newR = parseSlashCommand("/plan new foo", {
208
+ currentSettings: DEFAULT_PLAN_SETTINGS,
209
+ availablePlanSlugs: [],
210
+ defaultPort: 4321,
211
+ });
212
+ await executeSideEffect(newR!.sideEffect!, makeCtx(), makeOpts());
213
+
214
+ const addR = parseSlashCommand('/plan add foo --title Elem1 --type task', {
215
+ currentSettings: DEFAULT_PLAN_SETTINGS,
216
+ availablePlanSlugs: ["foo"],
217
+ defaultPort: 4321,
218
+ });
219
+ await executeSideEffect(addR!.sideEffect!, makeCtx(), makeOpts());
220
+
221
+ // Read back to get element id
222
+ const planPath = join(worktree, "plans", "foo", "plan.json");
223
+ let canvas = JSON.parse(readFileSync(planPath, "utf-8")) as {
224
+ elements: Array<{ id: string; title: string }>;
225
+ };
226
+ expect(canvas.elements).toHaveLength(1);
227
+ const elementId = canvas.elements[0]!.id;
228
+
229
+ // Now delete it
230
+ const delR = parseSlashCommand(`/plan delete foo ${elementId}`, {
231
+ currentSettings: DEFAULT_PLAN_SETTINGS,
232
+ availablePlanSlugs: ["foo"],
233
+ defaultPort: 4321,
234
+ });
235
+ await executeSideEffect(delR!.sideEffect!, makeCtx(), makeOpts());
236
+
237
+ canvas = JSON.parse(readFileSync(planPath, "utf-8"));
238
+ expect(canvas.elements).toHaveLength(0);
239
+ });
240
+ });
241
+
242
+ describe("integration: /plan wait", () => {
243
+ test("/plan wait foo → returns deferred response, does NOT block hook (< 100ms)", async () => {
244
+ const r = parseSlashCommand("/plan wait foo", {
245
+ currentSettings: DEFAULT_PLAN_SETTINGS,
246
+ availablePlanSlugs: ["foo"],
247
+ defaultPort: 4321,
248
+ });
249
+ expect(r).not.toBeNull();
250
+ expect(r!.response).toMatch(/deferred/i);
251
+ expect(r!.response).toMatch(/bizar_wait_for_feedback/);
252
+ expect(r!.sideEffect).toBeUndefined(); // wait is NOT a tool_invocation
253
+
254
+ // Verify it returns instantly (no async I/O)
255
+ const start = Date.now();
256
+ await executeSideEffect({ kind: "list_plans" }, makeCtx(), makeOpts()); // no-op
257
+ const elapsed = Date.now() - start;
258
+ expect(elapsed).toBeLessThan(100);
259
+ });
260
+ });
261
+
262
+ describe("integration: /plan new on existing slug", () => {
263
+ test("/plan new foo on existing slug → refusal response, existing files intact", async () => {
264
+ // Create the plan
265
+ const r1 = parseSlashCommand("/plan new foo", {
266
+ currentSettings: DEFAULT_PLAN_SETTINGS,
267
+ availablePlanSlugs: [],
268
+ defaultPort: 4321,
269
+ });
270
+ await executeSideEffect(r1!.sideEffect!, makeCtx(), makeOpts());
271
+
272
+ const planDir = join(worktree, "plans", "foo");
273
+ const metaBefore = readFileSync(join(planDir, "meta.json"), "utf-8");
274
+ const planBefore = readFileSync(join(planDir, "plan.json"), "utf-8");
275
+
276
+ // Try to create again
277
+ const r2 = parseSlashCommand("/plan new foo", {
278
+ currentSettings: DEFAULT_PLAN_SETTINGS,
279
+ availablePlanSlugs: ["foo"],
280
+ defaultPort: 4321,
281
+ });
282
+ const execR = await executeSideEffect(r2!.sideEffect!, makeCtx(), makeOpts());
283
+
284
+ // Should have refused
285
+ expect(execR.responseOverride).toMatch(/already exists/);
286
+
287
+ // Files should be unchanged
288
+ expect(readFileSync(join(planDir, "meta.json"), "utf-8")).toBe(metaBefore);
289
+ expect(readFileSync(join(planDir, "plan.json"), "utf-8")).toBe(planBefore);
290
+ });
291
+ });
292
+
293
+ describe("integration: /plan comment", () => {
294
+ test('/plan comment foo "first comment" → plan.json contains the comment', async () => {
295
+ // Create the plan first
296
+ const newR = parseSlashCommand("/plan new foo", {
297
+ currentSettings: DEFAULT_PLAN_SETTINGS,
298
+ availablePlanSlugs: [],
299
+ defaultPort: 4321,
300
+ });
301
+ await executeSideEffect(newR!.sideEffect!, makeCtx(), makeOpts());
302
+
303
+ const commentR = parseSlashCommand('/plan comment foo "first comment"', {
304
+ currentSettings: DEFAULT_PLAN_SETTINGS,
305
+ availablePlanSlugs: ["foo"],
306
+ defaultPort: 4321,
307
+ });
308
+ await executeSideEffect(commentR!.sideEffect!, makeCtx(), makeOpts());
309
+
310
+ const planPath = join(worktree, "plans", "foo", "plan.json");
311
+ const canvas = JSON.parse(readFileSync(planPath, "utf-8")) as {
312
+ comments: Array<{ id: string; text: string; author: string }>;
313
+ };
314
+ expect(canvas.comments).toHaveLength(1);
315
+ expect(canvas.comments[0]!.text).toBe("first comment");
316
+ expect(canvas.comments[0]!.author).toBe("user");
317
+ });
318
+ });
319
+
320
+ describe("integration: /plan comments", () => {
321
+ test("/plan comments foo → response lists the comment", async () => {
322
+ // Create the plan first
323
+ const newR = parseSlashCommand("/plan new foo", {
324
+ currentSettings: DEFAULT_PLAN_SETTINGS,
325
+ availablePlanSlugs: [],
326
+ defaultPort: 4321,
327
+ });
328
+ await executeSideEffect(newR!.sideEffect!, makeCtx(), makeOpts());
329
+
330
+ // Add a comment
331
+ const commentR = parseSlashCommand('/plan comment foo "Hello world"', {
332
+ currentSettings: DEFAULT_PLAN_SETTINGS,
333
+ availablePlanSlugs: ["foo"],
334
+ defaultPort: 4321,
335
+ });
336
+ await executeSideEffect(commentR!.sideEffect!, makeCtx(), makeOpts());
337
+
338
+ // Now list comments
339
+ const listR = parseSlashCommand("/plan comments foo", {
340
+ currentSettings: DEFAULT_PLAN_SETTINGS,
341
+ availablePlanSlugs: ["foo"],
342
+ defaultPort: 4321,
343
+ });
344
+ const execR = await executeSideEffect(listR!.sideEffect!, makeCtx(), makeOpts());
345
+ const response = execR.responseOverride ?? listR!.response;
346
+ expect(response).toMatch(/Hello world/);
347
+ });
348
+ });