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