@polderlabs/bizar-plugin 0.5.4 → 0.6.1

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.
@@ -9,6 +9,7 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
9
9
  import { StateStore, SessionState, EMPTY_STATE } from "../src/state";
10
10
  import { mkdirSync, rmSync, writeFileSync, existsSync, utimesSync } from "node:fs";
11
11
  import path from "node:path";
12
+ import os from "node:os";
12
13
 
13
14
  // Minimal mock logger that collects all messages
14
15
  class MockLogger {
@@ -18,7 +19,7 @@ class MockLogger {
18
19
  }
19
20
  }
20
21
 
21
- const TEST_DIR = "/tmp/bizar-state-test";
22
+ const TEST_DIR = path.join(os.tmpdir(), "bizar-state-test");
22
23
  const TEST_SESSION_A = "session-a-123";
23
24
  const TEST_SESSION_B = "session-b-456";
24
25
 
@@ -31,7 +31,7 @@ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
31
31
  import { join } from "node:path";
32
32
  import { tmpdir } from "node:os";
33
33
 
34
- import { readPlanComments } from "../../src/tools/bg-get-comments.ts";
34
+ import { readPlanComments } from "../../src/tools/bg-get-comments.js";
35
35
 
36
36
  // ---------------------------------------------------------------------------
37
37
  // Fixtures / helpers
@@ -482,4 +482,4 @@ describe("readPlanComments — thread replies", () => {
482
482
  expect(result.comments[0]?.thread).toBeUndefined();
483
483
  }
484
484
  });
485
- });
485
+ });
@@ -121,16 +121,20 @@ describe("bizar_kill — POST /session/{id}/abort (HIGH-4)", () => {
121
121
  const instances = makeInstances();
122
122
  bizar_kill({ instanceId: "bgr_running" }, instances);
123
123
  expect(httpCalls).toHaveLength(1);
124
- expect(httpCalls[0].method).toBe("POST");
125
- expect(httpCalls[0].path).toContain("/abort");
124
+ const first = httpCalls[0];
125
+ expect(first).toBeDefined();
126
+ expect(first?.method).toBe("POST");
127
+ expect(first?.path).toContain("/abort");
126
128
  });
127
129
 
128
130
  it("calls POST /session/{id}/abort for pending instance", () => {
129
131
  const instances = makeInstances();
130
132
  bizar_kill({ instanceId: "bgr_pending" }, instances);
131
133
  expect(httpCalls).toHaveLength(1);
132
- expect(httpCalls[0].method).toBe("POST");
133
- expect(httpCalls[0].path).toContain("/abort");
134
+ const first = httpCalls[0];
135
+ expect(first).toBeDefined();
136
+ expect(first?.method).toBe("POST");
137
+ expect(first?.path).toContain("/abort");
134
138
  });
135
139
 
136
140
  it("does NOT call DELETE /session/{id} (HIGH-4)", () => {
@@ -228,4 +232,4 @@ describe("bizar_kill — unknown instance", () => {
228
232
  expect(result).toHaveProperty("error");
229
233
  expect((result as { error: string }).error).toContain("not found");
230
234
  });
231
- });
235
+ });
@@ -27,13 +27,13 @@ function parseModel(model: string | undefined): { providerID: string; modelID: s
27
27
  const parts = model.split("/");
28
28
  if (parts.length !== 2) {
29
29
  throw new Error(
30
- `model must be in "providerID/modelID" format (e.g. "minimax/MiniMax-M3"). Omit to use the agent's default.`,
30
+ `model must be in "providerID/modelID" format (e.g. "openrouter/minimax-m3"). Omit to use the agent's default.`,
31
31
  );
32
32
  }
33
33
  const [providerID, modelID] = parts;
34
34
  if (!providerID || !modelID) {
35
35
  throw new Error(
36
- `model must be in "providerID/modelID" format (e.g. "minimax/MiniMax-M3"). Omit to use the agent's default.`,
36
+ `model must be in "providerID/modelID" format (e.g. "openrouter/minimax-m3"). Omit to use the agent's default.`,
37
37
  );
38
38
  }
39
39
  return { providerID, modelID };
@@ -132,9 +132,9 @@ describe("bizar_spawn_background — Odin-only", () => {
132
132
  // ---------------------------------------------------------------------------
133
133
 
134
134
  describe("bizar_spawn_background — model parsing (HIGH-3, LOW-34)", () => {
135
- it('"minimax/MiniMax-M3" parses to { providerID: "minimax", modelID: "MiniMax-M3" }', () => {
136
- const result = parseModel("minimax/MiniMax-M3");
137
- expect(result).toEqual({ providerID: "minimax", modelID: "MiniMax-M3" });
135
+ it('"openrouter/minimax-m3" parses to { providerID: "openrouter", modelID: "minimax-m3" }', () => {
136
+ const result = parseModel("openrouter/minimax-m3");
137
+ expect(result).toEqual({ providerID: "openrouter", modelID: "minimax-m3" });
138
138
  });
139
139
 
140
140
  it('"opencode/deepseek-v4-flash-free" parses correctly', () => {
@@ -142,20 +142,20 @@ describe("bizar_spawn_background — model parsing (HIGH-3, LOW-34)", () => {
142
142
  expect(result).toEqual({ providerID: "opencode", modelID: "deepseek-v4-flash-free" });
143
143
  });
144
144
 
145
- it('"MiniMax-M3" (no /) is rejected', () => {
146
- expect(() => parseModel("MiniMax-M3")).toThrow();
145
+ it('"minimax-m3" (no /) is rejected', () => {
146
+ expect(() => parseModel("minimax-m3")).toThrow();
147
147
  });
148
148
 
149
149
  it('"a/b/c" (multiple /) is rejected', () => {
150
150
  expect(() => parseModel("a/b/c")).toThrow();
151
151
  });
152
152
 
153
- it('"minimax/" (empty modelID) is rejected', () => {
154
- expect(() => parseModel("minimax/")).toThrow();
153
+ it('"openrouter/" (empty modelID) is rejected', () => {
154
+ expect(() => parseModel("openrouter/")).toThrow();
155
155
  });
156
156
 
157
- it('"/MiniMax-M3" (empty providerID) is rejected', () => {
158
- expect(() => parseModel("/MiniMax-M3")).toThrow();
157
+ it('"/minimax-m3" (empty providerID) is rejected', () => {
158
+ expect(() => parseModel("/minimax-m3")).toThrow();
159
159
  });
160
160
 
161
161
  it("undefined model returns undefined (agent uses its default)", () => {
@@ -168,7 +168,7 @@ describe("bizar_spawn_background — model parsing (HIGH-3, LOW-34)", () => {
168
168
 
169
169
  it("spawn tool includes parsed model in POST /session body", () => {
170
170
  const result = bizarre_spawn_background(
171
- { agent: "mimir", prompt: "Do X", model: "minimax/MiniMax-M3" },
171
+ { agent: "mimir", prompt: "Do X", model: "openrouter/minimax-m3" },
172
172
  { agent: "odin", sessionID: "sess_parent", worktree: "/tmp" },
173
173
  );
174
174
  expect(result).not.toHaveProperty("error");
@@ -16,6 +16,7 @@ interface BackgroundState {
16
16
  agent: string;
17
17
  status: "pending" | "running" | "done" | "failed" | "killed" | "timed_out";
18
18
  startedAt: number;
19
+ completedAt?: number;
19
20
  toolCallCount: number;
20
21
  promptPreview: string;
21
22
  resultPreview?: string;
@@ -213,4 +214,4 @@ describe("bizar_status — durationMs", () => {
213
214
  const expectedDuration = completedAt - startedAt;
214
215
  expect(expectedDuration).toBe(30_000);
215
216
  });
216
- });
217
+ });
@@ -36,7 +36,7 @@ import {
36
36
  import { join } from "node:path";
37
37
  import { tmpdir } from "node:os";
38
38
 
39
- import { planAction } from "../../src/tools/plan-action.ts";
39
+ import { planAction } from "../../src/tools/plan-action.js";
40
40
 
41
41
  // ---------------------------------------------------------------------------
42
42
  // Fixtures
@@ -596,4 +596,4 @@ describe("planAction — unknown action", () => {
596
596
  expect(r.ok).toBe(false);
597
597
  if (!r.ok) expect(r.error).toMatch(/Unknown action/);
598
598
  });
599
- });
599
+ });
@@ -28,7 +28,7 @@ import {
28
28
  import { join } from "node:path";
29
29
  import { tmpdir } from "node:os";
30
30
 
31
- import { waitForFeedback } from "../../src/tools/wait-for-feedback.ts";
31
+ import { waitForFeedback } from "../../src/tools/wait-for-feedback.js";
32
32
 
33
33
  // ---------------------------------------------------------------------------
34
34
  // Fixtures
@@ -387,4 +387,4 @@ describe("waitForFeedback — missing plan", () => {
387
387
  expect(r.error).toMatch(/Plan not found/);
388
388
  }
389
389
  });
390
- });
390
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Regression tests for the background-state mutex.
3
+ *
4
+ * BUG: `InstanceManager.update()` acquired the per-instance lock and then
5
+ * called `BackgroundStateStore.save()`, which tried to acquire the same lock
6
+ * again. That nested re-entry never resolved, so startup deadlocked while
7
+ * `rebuildInMemoryMap()` tried to mark pending/running instances failed.
8
+ */
9
+
10
+ import { afterEach, describe, expect, it } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+
15
+ import { InstanceManager, type AddDraft } from "../src/background.js";
16
+ import {
17
+ BackgroundStateStore,
18
+ type BackgroundState,
19
+ } from "../src/background-state.js";
20
+
21
+ const TEST_DIRS: string[] = [];
22
+
23
+ const logger = {
24
+ log: () => {},
25
+ debug: () => {},
26
+ info: () => {},
27
+ warn: () => {},
28
+ error: () => {},
29
+ };
30
+
31
+ class FakeEventStream {
32
+ onSessionEvent(): () => void {
33
+ return () => {};
34
+ }
35
+ }
36
+
37
+ function makeTempStateDir(): string {
38
+ const dir = mkdtempSync(path.join(os.tmpdir(), "bizar-update-deadlock-"));
39
+ TEST_DIRS.push(dir);
40
+ return dir;
41
+ }
42
+
43
+ function makeDraft(overrides: Partial<AddDraft> = {}): AddDraft {
44
+ return {
45
+ instanceId: "bgr_deadlock_test",
46
+ sessionId: "",
47
+ agent: "odin",
48
+ model: "agent-default",
49
+ promptPreview: "Investigate startup deadlock",
50
+ resultPreview: undefined,
51
+ resultMessageIds: undefined,
52
+ error: undefined,
53
+ parentAgent: "odin",
54
+ parentInstanceId: undefined,
55
+ logPath: path.join(os.tmpdir(), "bgr_deadlock_test.log"),
56
+ timeoutMs: 30_000,
57
+ toolCallCount: 0,
58
+ loopGuardTool: undefined,
59
+ lastEventAt: undefined,
60
+ lastToolOrTextAt: undefined,
61
+ interventionCount: undefined,
62
+ interventionAt: undefined,
63
+ interventionReason: undefined,
64
+ ...overrides,
65
+ };
66
+ }
67
+
68
+ function createManager(stateDir: string): {
69
+ manager: InstanceManager;
70
+ stateStore: BackgroundStateStore;
71
+ } {
72
+ const stateStore = new BackgroundStateStore(stateDir, logger);
73
+ const manager = new InstanceManager({
74
+ stateStore,
75
+ maxConcurrent: 8,
76
+ toolCallCap: 250,
77
+ logger,
78
+ serve: { worktree: "/tmp" } as never,
79
+ http: {} as never,
80
+ stream: new FakeEventStream() as never,
81
+ stallTimeoutMs: 180_000,
82
+ thinkingLoopTimeoutMs: 300_000,
83
+ maxInterventions: 1,
84
+ });
85
+ manager.disablePeriodicChecks();
86
+ return { manager, stateStore };
87
+ }
88
+
89
+ async function settleWithin<T>(promise: Promise<T>, timeoutMs: number): Promise<T | "timeout"> {
90
+ return await Promise.race([
91
+ promise,
92
+ new Promise<"timeout">((resolve) => {
93
+ setTimeout(() => resolve("timeout"), timeoutMs);
94
+ }),
95
+ ]);
96
+ }
97
+
98
+ afterEach(() => {
99
+ for (const dir of TEST_DIRS.splice(0)) {
100
+ rmSync(dir, { recursive: true, force: true });
101
+ }
102
+ });
103
+
104
+ describe("InstanceManager.update mutex regression", () => {
105
+ it("resolves and persists instead of deadlocking on nested save", async () => {
106
+ const stateDir = makeTempStateDir();
107
+ const { manager, stateStore } = createManager(stateDir);
108
+ const draft = makeDraft();
109
+
110
+ await manager.add(draft);
111
+
112
+ const result = await settleWithin(
113
+ manager.update(draft.instanceId, {
114
+ status: "failed",
115
+ error: "plugin restarted while instance was pending",
116
+ }),
117
+ 250,
118
+ );
119
+
120
+ expect(result).not.toBe("timeout");
121
+
122
+ const stored = await stateStore.load(draft.instanceId);
123
+ expect(stored?.status).toBe("failed");
124
+ });
125
+
126
+ it("rebuildInMemoryMap marks a pending instance failed without hanging", async () => {
127
+ const stateDir = makeTempStateDir();
128
+ const { manager, stateStore } = createManager(stateDir);
129
+ const pendingState: BackgroundState = {
130
+ ...makeDraft(),
131
+ status: "pending",
132
+ startedAt: Date.now(),
133
+ };
134
+
135
+ await stateStore.save(pendingState);
136
+
137
+ const result = await settleWithin(manager.rebuildInMemoryMap(), 250);
138
+
139
+ expect(result).not.toBe("timeout");
140
+
141
+ const stored = await stateStore.load(pendingState.instanceId);
142
+ expect(stored?.error).toBe("plugin restarted while instance was pending");
143
+ });
144
+ });