@polderlabs/bizar-plugin 0.8.0 → 0.8.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.
- package/README.md +1 -1
- package/index.ts +1 -0
- package/package.json +2 -2
- package/src/background.ts +195 -76
- package/src/opencode-runner.ts +47 -19
- package/tests/attach-handler-bug.test.ts +2 -1
- package/tests/background-state.test.ts +1 -1
- package/tests/background.test.ts +1 -1
- package/tests/stall-think.test.ts +6 -6
- package/tests/tools/bg-spawn.test.ts +6 -6
- package/tests/tools/opencode-runner.test.ts +115 -0
- package/tests/update-deadlock.test.ts +1 -0
package/README.md
CHANGED
|
@@ -155,7 +155,7 @@ The plugin starts one `opencode serve` process on init (single-serve, multi-sess
|
|
|
155
155
|
const result = await bizarre_spawn_background({
|
|
156
156
|
agent: "mimir", // which agent to run
|
|
157
157
|
prompt: "Research X and return findings", // what to do
|
|
158
|
-
model: "
|
|
158
|
+
model: "minimax/minimax-m3", // optional: override model
|
|
159
159
|
timeoutMs: 300_000, // optional: default 5 min, max 30 min
|
|
160
160
|
}, ctx);
|
|
161
161
|
console.log(result.instanceId); // "bgr_01ARSH3J5V..."
|
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polderlabs/bizar-plugin",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Bizar opencode plugin — loop detection, status reporting, handoff signal, background agents, and slash commands + visual plan flow for subagent activity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"check:imports": "bash scripts/check-forbidden-imports.sh",
|
|
13
13
|
"typecheck": "tsc --noEmit",
|
|
14
|
-
"test": "npm run check:imports && bun test tests/loop.test.ts tests/block.test.ts tests/stall-think.test.ts tests/tools/bg-get-comments.test.ts tests/settings.test.ts tests/commands.test.ts tests/commands-impl.test.ts tests/tools/plan-action.test.ts tests/tools/wait-for-feedback.test.ts"
|
|
14
|
+
"test": "npm run check:imports && bun test tests/loop.test.ts tests/block.test.ts tests/stall-think.test.ts tests/tools/bg-get-comments.test.ts tests/tools/opencode-runner.test.ts tests/settings.test.ts tests/commands.test.ts tests/commands-impl.test.ts tests/tools/plan-action.test.ts tests/tools/wait-for-feedback.test.ts"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
17
|
"opencode",
|
package/src/background.ts
CHANGED
|
@@ -145,9 +145,16 @@ export class InstanceManager {
|
|
|
145
145
|
private maxConcurrent: number;
|
|
146
146
|
private toolCallCap: number;
|
|
147
147
|
private logger: Logger;
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
// v0.8.0 — `serve`, `http`, `stream` are nullable to support the
|
|
149
|
+
// bg-only mode used when the opencode serve child is unavailable
|
|
150
|
+
// (BIZAR_SERVE_DISABLE=1, startup failure) or when this process IS a
|
|
151
|
+
// bg-spawned `opencode run` subprocess. In bg-only mode, every method
|
|
152
|
+
// that would otherwise call `this.http.X` or `this.stream.X` is a
|
|
153
|
+
// no-op; state transitions still happen via the runner's `onExit`
|
|
154
|
+
// callback (see src/tools/bg-spawn.ts).
|
|
155
|
+
private serve: ServeLifecycle | null;
|
|
156
|
+
private http: HttpClient | null;
|
|
157
|
+
private stream: EventStream | null;
|
|
151
158
|
private worktree: string;
|
|
152
159
|
// v0.3.0 — stall and thinking-loop protection
|
|
153
160
|
private stallTimeoutMs: number;
|
|
@@ -163,9 +170,13 @@ export class InstanceManager {
|
|
|
163
170
|
maxConcurrent: number;
|
|
164
171
|
toolCallCap: number;
|
|
165
172
|
logger: Logger;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
173
|
+
// v0.8.0 — `worktree` is now an explicit param (was previously
|
|
174
|
+
// derived from `opts.serve.worktree`, which is impossible when
|
|
175
|
+
// `serve` is null in bg-only mode).
|
|
176
|
+
worktree: string;
|
|
177
|
+
serve: ServeLifecycle | null;
|
|
178
|
+
http: HttpClient | null;
|
|
179
|
+
stream: EventStream | null;
|
|
169
180
|
// v0.3.0
|
|
170
181
|
stallTimeoutMs?: number;
|
|
171
182
|
thinkingLoopTimeoutMs?: number;
|
|
@@ -178,7 +189,7 @@ export class InstanceManager {
|
|
|
178
189
|
this.serve = opts.serve;
|
|
179
190
|
this.http = opts.http;
|
|
180
191
|
this.stream = opts.stream;
|
|
181
|
-
this.worktree = opts.
|
|
192
|
+
this.worktree = opts.worktree;
|
|
182
193
|
this.stallTimeoutMs = Math.max(
|
|
183
194
|
1_000,
|
|
184
195
|
Math.floor(opts.stallTimeoutMs ?? 180_000),
|
|
@@ -188,13 +199,23 @@ export class InstanceManager {
|
|
|
188
199
|
Math.floor(opts.thinkingLoopTimeoutMs ?? 300_000),
|
|
189
200
|
);
|
|
190
201
|
this.maxInterventions = Math.max(1, Math.floor(opts.maxInterventions ?? 1));
|
|
191
|
-
// Schedule the periodic stall + thinking-loop checker
|
|
192
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
202
|
+
// Schedule the periodic stall + thinking-loop checker ONLY when we
|
|
203
|
+
// have a working HTTP client. In bg-only mode the runner owns the
|
|
204
|
+
// subprocess lifecycle, so the checker has nothing to do.
|
|
205
|
+
if (this.http !== null) {
|
|
206
|
+
this.stallCheckerTimer = setInterval(
|
|
207
|
+
() => void this.runStallAndLoopChecks(),
|
|
208
|
+
STALL_CHECK_INTERVAL_MS,
|
|
209
|
+
);
|
|
210
|
+
this.stallCheckerTimer.unref?.();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** True iff the manager was constructed without an HTTP client (no
|
|
215
|
+
* opencode serve child reachable). HTTP-dependent operations are
|
|
216
|
+
* no-ops in this mode. */
|
|
217
|
+
get isBgOnly(): boolean {
|
|
218
|
+
return this.http === null;
|
|
198
219
|
}
|
|
199
220
|
|
|
200
221
|
// --- Getters ------------------------------------------------------------
|
|
@@ -238,6 +259,11 @@ export class InstanceManager {
|
|
|
238
259
|
*/
|
|
239
260
|
async runStallAndLoopChecks(): Promise<void> {
|
|
240
261
|
if (this.stallCheckerDisabled) return;
|
|
262
|
+
// v0.8.0 — bg-only mode has no HTTP client, so stall and
|
|
263
|
+
// intervention are no-ops. The constructor also skips registering
|
|
264
|
+
// the interval in this mode, but a stray call from a test or future
|
|
265
|
+
// caller must still be safe.
|
|
266
|
+
if (this.http === null) return;
|
|
241
267
|
// Snapshot the instance ids so we do not iterate while the map mutates.
|
|
242
268
|
const ids: string[] = [];
|
|
243
269
|
for (const inst of this.instances.values()) {
|
|
@@ -424,6 +450,23 @@ export class InstanceManager {
|
|
|
424
450
|
);
|
|
425
451
|
return;
|
|
426
452
|
}
|
|
453
|
+
// v0.8.0 — bg-only mode has no HTTP client. The subprocess is
|
|
454
|
+
// already owned by opencode-runner.ts (see src/tools/bg-spawn.ts);
|
|
455
|
+
// mark the instance killed in-memory and let the runner notice the
|
|
456
|
+
// status change when the process eventually exits. We do NOT try
|
|
457
|
+
// to kill the OS process from here — that's the runner's job and
|
|
458
|
+
// we don't have a clean processId reference in bg-only mode
|
|
459
|
+
// (the runner does, but it lives in a separate module).
|
|
460
|
+
if (this.http === null) {
|
|
461
|
+
this.logger.warn(
|
|
462
|
+
`bizar: kill(${instanceId}) in bg-only mode: marking killed; subprocess will be reaped by opencode-runner.ts on exit`,
|
|
463
|
+
);
|
|
464
|
+
await this.update(instanceId, {
|
|
465
|
+
status: "killed",
|
|
466
|
+
completedAt: Date.now(),
|
|
467
|
+
});
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
427
470
|
// Abort the opencode session. The next SSE event for this session
|
|
428
471
|
// (EventSessionIdle or EventSessionError) will finalize the status.
|
|
429
472
|
const abort = await this.http.abortSession(inst.sessionId, this.worktree);
|
|
@@ -523,6 +566,80 @@ export class InstanceManager {
|
|
|
523
566
|
|
|
524
567
|
// --- Collect ------------------------------------------------------------
|
|
525
568
|
|
|
569
|
+
/**
|
|
570
|
+
* Wait for an instance to reach a terminal status, or until `deadline`
|
|
571
|
+
* (ms epoch) is reached. Returns `true` on terminal, `false` on
|
|
572
|
+
* timeout.
|
|
573
|
+
*
|
|
574
|
+
* Two implementations:
|
|
575
|
+
* - **With HTTP+stream** (full mode): subscribe to the SSE session
|
|
576
|
+
* event stream AND poll the in-memory map. SSE gives sub-second
|
|
577
|
+
* resolution; the in-memory check covers terminal states we set
|
|
578
|
+
* ourselves (tool-call cap, loop guard, intervention abort).
|
|
579
|
+
* - **Bg-only mode** (no HTTP, no SSE): poll the in-memory map
|
|
580
|
+
* every 500 ms. Terminal transitions come from the runner's
|
|
581
|
+
* `onExit` callback (see src/tools/bg-spawn.ts) which updates
|
|
582
|
+
* the instance state directly.
|
|
583
|
+
*/
|
|
584
|
+
private async waitForTerminal(
|
|
585
|
+
instanceId: string,
|
|
586
|
+
deadline: number,
|
|
587
|
+
): Promise<boolean> {
|
|
588
|
+
// Already terminal?
|
|
589
|
+
const initial = this.instances.get(instanceId);
|
|
590
|
+
if (initial && TERMINAL_STATUSES.has(initial.status)) return true;
|
|
591
|
+
|
|
592
|
+
if (this.stream === null || initial === undefined) {
|
|
593
|
+
// Bg-only path: poll the in-memory map.
|
|
594
|
+
const POLL_MS = 500;
|
|
595
|
+
while (Date.now() < deadline) {
|
|
596
|
+
await new Promise<void>((resolve) => setTimeout(resolve, POLL_MS));
|
|
597
|
+
const cur = this.instances.get(instanceId);
|
|
598
|
+
if (!cur) return false;
|
|
599
|
+
if (TERMINAL_STATUSES.has(cur.status)) return true;
|
|
600
|
+
}
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Full path: subscribe to the session event stream AND observe our
|
|
605
|
+
// own in-memory state changes for terminal transitions we set
|
|
606
|
+
// ourselves (tool-cap, loop guard, intervention abort).
|
|
607
|
+
return await new Promise<boolean>((resolve) => {
|
|
608
|
+
const remaining = Math.max(0, deadline - Date.now());
|
|
609
|
+
if (remaining === 0) {
|
|
610
|
+
resolve(false);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const timer = setTimeout(() => {
|
|
614
|
+
unsubscribe();
|
|
615
|
+
resolve(false);
|
|
616
|
+
}, remaining);
|
|
617
|
+
const unsubscribe = this.stream!.onSessionEvent(
|
|
618
|
+
initial.sessionId,
|
|
619
|
+
(ev) => {
|
|
620
|
+
if (ev.type === "session.idle" || ev.type === "session.error") {
|
|
621
|
+
clearTimeout(timer);
|
|
622
|
+
unsubscribe();
|
|
623
|
+
resolve(true);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
const cur = this.instances.get(instanceId);
|
|
627
|
+
if (cur && TERMINAL_STATUSES.has(cur.status)) {
|
|
628
|
+
clearTimeout(timer);
|
|
629
|
+
unsubscribe();
|
|
630
|
+
resolve(true);
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
);
|
|
634
|
+
const cur = this.instances.get(instanceId);
|
|
635
|
+
if (cur && TERMINAL_STATUSES.has(cur.status)) {
|
|
636
|
+
clearTimeout(timer);
|
|
637
|
+
unsubscribe();
|
|
638
|
+
resolve(true);
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
526
643
|
/**
|
|
527
644
|
* Wait for the instance to reach a terminal state (or until
|
|
528
645
|
* `timeoutMs` elapses), then build the result string per spec §4.4.
|
|
@@ -540,42 +657,7 @@ export class InstanceManager {
|
|
|
540
657
|
|
|
541
658
|
// 1. Wait for terminal state.
|
|
542
659
|
if (!TERMINAL_STATUSES.has(inst.status)) {
|
|
543
|
-
const reachedTerminal = await
|
|
544
|
-
const remaining = Math.max(0, deadline - Date.now());
|
|
545
|
-
if (remaining === 0) {
|
|
546
|
-
resolve(false);
|
|
547
|
-
return;
|
|
548
|
-
}
|
|
549
|
-
const timer = setTimeout(() => {
|
|
550
|
-
unsubscribe();
|
|
551
|
-
resolve(false);
|
|
552
|
-
}, remaining);
|
|
553
|
-
const unsubscribe = this.stream.onSessionEvent(inst.sessionId, (ev) => {
|
|
554
|
-
if (
|
|
555
|
-
ev.type === "session.idle" ||
|
|
556
|
-
ev.type === "session.error"
|
|
557
|
-
) {
|
|
558
|
-
clearTimeout(timer);
|
|
559
|
-
unsubscribe();
|
|
560
|
-
resolve(true);
|
|
561
|
-
return;
|
|
562
|
-
}
|
|
563
|
-
// Also resolve on tool-cap / loop-guard (which we set ourselves).
|
|
564
|
-
const cur = this.instances.get(instanceId);
|
|
565
|
-
if (cur && TERMINAL_STATUSES.has(cur.status)) {
|
|
566
|
-
clearTimeout(timer);
|
|
567
|
-
unsubscribe();
|
|
568
|
-
resolve(true);
|
|
569
|
-
}
|
|
570
|
-
});
|
|
571
|
-
// Re-check after subscribing in case the state already changed.
|
|
572
|
-
const cur = this.instances.get(instanceId);
|
|
573
|
-
if (cur && TERMINAL_STATUSES.has(cur.status)) {
|
|
574
|
-
clearTimeout(timer);
|
|
575
|
-
unsubscribe();
|
|
576
|
-
resolve(true);
|
|
577
|
-
}
|
|
578
|
-
});
|
|
660
|
+
const reachedTerminal = await this.waitForTerminal(instanceId, deadline);
|
|
579
661
|
if (!reachedTerminal) {
|
|
580
662
|
// Timed out. Return what we have.
|
|
581
663
|
const final = this.instances.get(instanceId);
|
|
@@ -599,7 +681,11 @@ export class InstanceManager {
|
|
|
599
681
|
}
|
|
600
682
|
|
|
601
683
|
// 2. Build the result. Fetch messages from the opencode server and
|
|
602
|
-
// concatenate the assistant text parts.
|
|
684
|
+
// concatenate the assistant text parts. In bg-only mode there is
|
|
685
|
+
// no HTTP client to ask, so we fall back to whatever
|
|
686
|
+
// `resultPreview` was captured during the run (often empty
|
|
687
|
+
// because there is no SSE stream in bg-only mode — the runner
|
|
688
|
+
// writes the raw output to the log file instead).
|
|
603
689
|
const final = this.instances.get(instanceId);
|
|
604
690
|
if (!final) {
|
|
605
691
|
throw new Error(`collect: instance ${instanceId} disappeared`);
|
|
@@ -695,13 +781,19 @@ export class InstanceManager {
|
|
|
695
781
|
completedAt: Date.now(),
|
|
696
782
|
});
|
|
697
783
|
}
|
|
698
|
-
// Phase 2: best-effort aborts in parallel, 5s per call.
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
784
|
+
// Phase 2: best-effort aborts in parallel, 5s per call. In bg-only
|
|
785
|
+
// mode there is no HTTP client to ask — the runner owns the
|
|
786
|
+
// subprocess lifecycle, so the in-memory status flip is the only
|
|
787
|
+
// signal we can emit. Skipped cleanly when http is null.
|
|
788
|
+
if (this.http !== null) {
|
|
789
|
+
const abortPromises = live.map((inst) =>
|
|
790
|
+
withTimeout(
|
|
791
|
+
this.http!.abortSession(inst.sessionId, this.worktree),
|
|
792
|
+
5_000,
|
|
793
|
+
).catch(() => undefined),
|
|
794
|
+
);
|
|
795
|
+
await Promise.allSettled(abortPromises);
|
|
796
|
+
}
|
|
705
797
|
this.logger.info(`bizar: shutdownAll complete (${live.length} instances aborted)`);
|
|
706
798
|
}
|
|
707
799
|
|
|
@@ -721,9 +813,13 @@ export class InstanceManager {
|
|
|
721
813
|
);
|
|
722
814
|
// Fire-and-forget. If the serve child is dead, this returns a
|
|
723
815
|
// failure result but we still mark the instance failed in-memory.
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
816
|
+
// v0.8.0 — bg-only mode has no HTTP client; the abort is a no-op
|
|
817
|
+
// there (the runner owns the subprocess).
|
|
818
|
+
if (this.http !== null) {
|
|
819
|
+
this.http
|
|
820
|
+
.abortSession(inst.sessionId, this.worktree)
|
|
821
|
+
.catch(() => undefined);
|
|
822
|
+
}
|
|
727
823
|
await this.update(inst.instanceId, {
|
|
728
824
|
status: "failed",
|
|
729
825
|
error: `No activity for ${this.stallTimeoutMs}ms — LLM appears stalled`,
|
|
@@ -751,15 +847,25 @@ export class InstanceManager {
|
|
|
751
847
|
`bizar: instance ${inst.instanceId} thinking loop (${sinceMs}ms without tool/text); sending intervention #${currentCount + 1}/${this.maxInterventions}`,
|
|
752
848
|
);
|
|
753
849
|
try {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
850
|
+
// v0.8.0 — bg-only mode has no HTTP client; interventions are a
|
|
851
|
+
// no-op there (the runner doesn't expose a "send a user message
|
|
852
|
+
// mid-run" hook). Logged as a debug so the operator can see why
|
|
853
|
+
// no intervention went out.
|
|
854
|
+
if (this.http === null) {
|
|
855
|
+
this.logger.debug(
|
|
856
|
+
`bizar: skipping intervention for ${inst.sessionId} (bg-only mode; no HTTP client)`,
|
|
857
|
+
);
|
|
858
|
+
} else {
|
|
859
|
+
await this.http.sendPrompt(
|
|
860
|
+
{
|
|
861
|
+
sessionId: inst.sessionId,
|
|
862
|
+
messageID,
|
|
863
|
+
agent: inst.agent,
|
|
864
|
+
parts: [{ type: "text", text: prompt }],
|
|
865
|
+
},
|
|
866
|
+
this.worktree,
|
|
867
|
+
);
|
|
868
|
+
}
|
|
763
869
|
} catch (err: unknown) {
|
|
764
870
|
// We swallow the error: the periodic checker will try again next
|
|
765
871
|
// tick. The intervention counter is still incremented below so
|
|
@@ -793,9 +899,13 @@ export class InstanceManager {
|
|
|
793
899
|
this.logger.warn(
|
|
794
900
|
`bizar: instance ${inst.instanceId} thinking loop exhausted ${this.maxInterventions} intervention(s) over ${sinceMs}ms; aborting`,
|
|
795
901
|
);
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
902
|
+
// v0.8.0 — bg-only mode has no HTTP client; the abort is a no-op
|
|
903
|
+
// there (the runner owns the subprocess).
|
|
904
|
+
if (this.http !== null) {
|
|
905
|
+
this.http
|
|
906
|
+
.abortSession(inst.sessionId, this.worktree)
|
|
907
|
+
.catch(() => undefined);
|
|
908
|
+
}
|
|
799
909
|
await this.update(inst.instanceId, {
|
|
800
910
|
status: "failed",
|
|
801
911
|
error: `Thinking loop detected: ${formatDuration(sinceMs)} of thinking without tool calls or output. Spawn a Mimir agent for research.`,
|
|
@@ -809,6 +919,8 @@ export class InstanceManager {
|
|
|
809
919
|
|
|
810
920
|
public attachEventHandler(inst: BackgroundState): () => void {
|
|
811
921
|
this.detachEventHandler(inst.instanceId);
|
|
922
|
+
// v0.8.0 — bg-only mode has no EventStream; return a no-op unsubscriber.
|
|
923
|
+
if (!this.stream) return () => {};
|
|
812
924
|
const handler: SessionEventHandler = (ev: StreamEvent) => {
|
|
813
925
|
void this.handleInstanceEvent(inst.instanceId, ev);
|
|
814
926
|
};
|
|
@@ -935,9 +1047,13 @@ export class InstanceManager {
|
|
|
935
1047
|
if (nextCount >= this.toolCallCap) {
|
|
936
1048
|
// Abort and mark failed. Use a fire-and-forget abort because we
|
|
937
1049
|
// do not want to block the handler on a network call.
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1050
|
+
// v0.8.0 — bg-only mode has no HTTP client; the abort is a
|
|
1051
|
+
// no-op there (the runner owns the subprocess).
|
|
1052
|
+
if (this.http !== null) {
|
|
1053
|
+
this.http
|
|
1054
|
+
.abortSession(inst.sessionId, this.worktree)
|
|
1055
|
+
.catch(() => undefined);
|
|
1056
|
+
}
|
|
941
1057
|
patch.status = "failed";
|
|
942
1058
|
patch.error = `Tool-call cap reached (${nextCount}). Aborted to prevent cost runaway.`;
|
|
943
1059
|
patch.completedAt = Date.now();
|
|
@@ -995,6 +1111,9 @@ export class InstanceManager {
|
|
|
995
1111
|
* - If `loopGuardTool` is set, prepend the marker.
|
|
996
1112
|
*/
|
|
997
1113
|
private async buildResultText(inst: BackgroundState): Promise<string> {
|
|
1114
|
+
// v0.8.0 — bg-only mode has no HTTP client; fall back to whatever
|
|
1115
|
+
// text-part preview we've already accumulated.
|
|
1116
|
+
if (!this.http) return inst.resultPreview ?? "";
|
|
998
1117
|
const res = await this.http.listMessages(inst.sessionId, this.worktree);
|
|
999
1118
|
if (!res.ok) {
|
|
1000
1119
|
this.logger.warn(`bizar: collect: listMessages failed: ${res.error}`);
|
package/src/opencode-runner.ts
CHANGED
|
@@ -105,6 +105,47 @@ const agents = new Map<number, AgentRecord>();
|
|
|
105
105
|
|
|
106
106
|
// --- Public API -----------------------------------------------------------
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Pure: build the argv array for `opencode run` from SpawnAgentOptions.
|
|
110
|
+
*
|
|
111
|
+
* Extracted from spawnAgent so it can be unit-tested without spawning
|
|
112
|
+
* a real process. Throws when opts.agent is empty — `opencode run`
|
|
113
|
+
* requires a known agent name; passing an empty string would silently
|
|
114
|
+
* fall back to opencode's default agent and break session attribution
|
|
115
|
+
* (the title prefix `bgr:<agent>:...` would also degrade to `bgr::...`).
|
|
116
|
+
*
|
|
117
|
+
* Arg layout (matches `opencode run --help`):
|
|
118
|
+
* opencode run
|
|
119
|
+
* --dir <worktree>
|
|
120
|
+
* --print-logs
|
|
121
|
+
* --log-level INFO
|
|
122
|
+
* --title <title>
|
|
123
|
+
* --agent <agent> ← REQUIRED; was missing pre-v0.8.1
|
|
124
|
+
* [--model <providerID>/<modelID>] ← optional override
|
|
125
|
+
* -- <prompt>
|
|
126
|
+
*/
|
|
127
|
+
export function buildOpencodeRunArgs(opts: SpawnAgentOptions): string[] {
|
|
128
|
+
if (!opts.agent) {
|
|
129
|
+
throw new Error("bizar_spawn_background: agent is required");
|
|
130
|
+
}
|
|
131
|
+
const args: string[] = [
|
|
132
|
+
"opencode",
|
|
133
|
+
"run",
|
|
134
|
+
"--dir", opts.worktree,
|
|
135
|
+
"--print-logs",
|
|
136
|
+
"--log-level", "INFO",
|
|
137
|
+
"--title", opts.title || `bgr:${opts.agent}:${Date.now()}`,
|
|
138
|
+
"--agent", opts.agent,
|
|
139
|
+
];
|
|
140
|
+
if (opts.model) {
|
|
141
|
+
args.push("--model", `${opts.model.providerID}/${opts.model.modelID}`);
|
|
142
|
+
}
|
|
143
|
+
// `--` separates flags from positional so a prompt starting with
|
|
144
|
+
// `-` is treated as a message.
|
|
145
|
+
args.push("--", opts.prompt);
|
|
146
|
+
return args;
|
|
147
|
+
}
|
|
148
|
+
|
|
108
149
|
/**
|
|
109
150
|
* Spawn a single `opencode run` process. The promise resolves once
|
|
110
151
|
* the opencode child has reported its session id in the structured
|
|
@@ -128,23 +169,10 @@ export async function spawnAgent(opts: SpawnAgentOptions): Promise<SpawnAgentRes
|
|
|
128
169
|
}
|
|
129
170
|
}
|
|
130
171
|
|
|
131
|
-
// 2. Build argv.
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
const args
|
|
135
|
-
"opencode",
|
|
136
|
-
"run",
|
|
137
|
-
"--dir", opts.worktree,
|
|
138
|
-
"--print-logs",
|
|
139
|
-
"--log-level", "INFO",
|
|
140
|
-
"--title", opts.title || `bgr:${opts.agent}:${Date.now()}`,
|
|
141
|
-
];
|
|
142
|
-
if (opts.model) {
|
|
143
|
-
args.push("--model", `${opts.model.providerID}/${opts.model.modelID}`);
|
|
144
|
-
}
|
|
145
|
-
// `--` separates flags from positional so a prompt starting with
|
|
146
|
-
// `-` is treated as a message.
|
|
147
|
-
args.push("--", opts.prompt);
|
|
172
|
+
// 2. Build argv. Pulled into a pure function so tests can assert the
|
|
173
|
+
// flag layout (notably the `--agent` flag and the migrated model
|
|
174
|
+
// ID format) without spawning a real `opencode run` process.
|
|
175
|
+
const args = buildOpencodeRunArgs(opts);
|
|
148
176
|
|
|
149
177
|
// 3. Spawn the process.
|
|
150
178
|
let proc: Subprocess;
|
|
@@ -227,8 +255,8 @@ export async function spawnAgent(opts: SpawnAgentOptions): Promise<SpawnAgentRes
|
|
|
227
255
|
// 6. Stream readers + exit handler — install BEFORE returning the
|
|
228
256
|
// promise so a fast-exiting process still produces a clean
|
|
229
257
|
// resolution.
|
|
230
|
-
const stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader()
|
|
231
|
-
const stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader()
|
|
258
|
+
const stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader() as unknown as ReadableStreamDefaultReader<Uint8Array<ArrayBufferLike>>;
|
|
259
|
+
const stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader() as unknown as ReadableStreamDefaultReader<Uint8Array<ArrayBufferLike>>;
|
|
232
260
|
void readStream(stderrReader, "stderr");
|
|
233
261
|
void readStream(stdoutReader, "stdout");
|
|
234
262
|
|
|
@@ -88,7 +88,7 @@ function makeDraft(overrides: Partial<BackgroundState> = {}): BackgroundState {
|
|
|
88
88
|
agent: "mimir",
|
|
89
89
|
status: "pending",
|
|
90
90
|
startedAt: Date.now(),
|
|
91
|
-
model: "
|
|
91
|
+
model: "minimax/minimax-m3",
|
|
92
92
|
promptPreview: "test",
|
|
93
93
|
resultPreview: undefined,
|
|
94
94
|
resultMessageIds: [],
|
|
@@ -124,6 +124,7 @@ describe("InstanceManager.add — empty sessionId (BUGFIX v0.5.1)", () => {
|
|
|
124
124
|
warn: () => {},
|
|
125
125
|
error: () => {},
|
|
126
126
|
} as never,
|
|
127
|
+
worktree: os.tmpdir(),
|
|
127
128
|
serve: { worktree: os.tmpdir() } as never,
|
|
128
129
|
http: {} as never,
|
|
129
130
|
stream: stream as never,
|
|
@@ -40,7 +40,7 @@ function makeState(overrides: Partial<BackgroundState> = {}): BackgroundState {
|
|
|
40
40
|
agent: "mimir",
|
|
41
41
|
status: "running",
|
|
42
42
|
startedAt: Date.now(),
|
|
43
|
-
model: "
|
|
43
|
+
model: "minimax/minimax-m3",
|
|
44
44
|
promptPreview: "Do the thing",
|
|
45
45
|
resultPreview: undefined,
|
|
46
46
|
resultMessageIds: [],
|
package/tests/background.test.ts
CHANGED
|
@@ -22,7 +22,7 @@ function makeBgState(overrides: Partial<BackgroundState> = {}): BackgroundState
|
|
|
22
22
|
agent: "mimir",
|
|
23
23
|
status: "pending",
|
|
24
24
|
startedAt: Date.now(),
|
|
25
|
-
model: "
|
|
25
|
+
model: "minimax/minimax-m3",
|
|
26
26
|
promptPreview: "Do the thing",
|
|
27
27
|
resultPreview: undefined,
|
|
28
28
|
resultMessageIds: [],
|
|
@@ -162,7 +162,7 @@ describe("BackgroundState schema backfill", () => {
|
|
|
162
162
|
agent: "mimir",
|
|
163
163
|
status: "running",
|
|
164
164
|
startedAt,
|
|
165
|
-
model: "
|
|
165
|
+
model: "minimax/minimax-m3",
|
|
166
166
|
promptPreview: "Do the thing",
|
|
167
167
|
toolCallCount: 0,
|
|
168
168
|
parentAgent: "odin",
|
|
@@ -189,7 +189,7 @@ describe("BackgroundState schema backfill", () => {
|
|
|
189
189
|
agent: "mimir",
|
|
190
190
|
status: "running",
|
|
191
191
|
startedAt,
|
|
192
|
-
model: "
|
|
192
|
+
model: "minimax/minimax-m3",
|
|
193
193
|
promptPreview: "Do the thing",
|
|
194
194
|
toolCallCount: 0,
|
|
195
195
|
parentAgent: "odin",
|
|
@@ -216,7 +216,7 @@ describe("BackgroundState schema backfill", () => {
|
|
|
216
216
|
agent: "mimir",
|
|
217
217
|
status: "running",
|
|
218
218
|
startedAt,
|
|
219
|
-
model: "
|
|
219
|
+
model: "minimax/minimax-m3",
|
|
220
220
|
promptPreview: "Do the thing",
|
|
221
221
|
toolCallCount: 0,
|
|
222
222
|
parentAgent: "odin",
|
|
@@ -437,7 +437,7 @@ function makeBgState(overrides: Partial<BackgroundState> = {}): BackgroundState
|
|
|
437
437
|
agent: "mimir",
|
|
438
438
|
status: "running",
|
|
439
439
|
startedAt: now,
|
|
440
|
-
model: "
|
|
440
|
+
model: "minimax/minimax-m3",
|
|
441
441
|
promptPreview: "Do the thing",
|
|
442
442
|
resultPreview: undefined,
|
|
443
443
|
resultMessageIds: [],
|
|
@@ -700,7 +700,7 @@ describe("bg-status toView — v0.3.0 fields", () => {
|
|
|
700
700
|
agent: "mimir",
|
|
701
701
|
status: "running",
|
|
702
702
|
startedAt: now - 600_000,
|
|
703
|
-
model: "
|
|
703
|
+
model: "minimax/minimax-m3",
|
|
704
704
|
promptPreview: "Research X",
|
|
705
705
|
toolCallCount: 0,
|
|
706
706
|
parentAgent: "odin",
|
|
@@ -729,7 +729,7 @@ describe("bg-status toView — v0.3.0 fields", () => {
|
|
|
729
729
|
agent: "mimir",
|
|
730
730
|
status: "running",
|
|
731
731
|
startedAt: now,
|
|
732
|
-
model: "
|
|
732
|
+
model: "minimax/minimax-m3",
|
|
733
733
|
promptPreview: "Research Y",
|
|
734
734
|
toolCallCount: 0,
|
|
735
735
|
parentAgent: "odin",
|
|
@@ -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. "
|
|
30
|
+
`model must be in "providerID/modelID" format (e.g. "minimax/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. "
|
|
36
|
+
`model must be in "providerID/modelID" format (e.g. "minimax/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('"
|
|
136
|
-
const result = parseModel("
|
|
137
|
-
expect(result).toEqual({ providerID: "
|
|
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" });
|
|
138
138
|
});
|
|
139
139
|
|
|
140
140
|
it('"opencode/deepseek-v4-flash-free" parses correctly', () => {
|
|
@@ -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: "
|
|
171
|
+
{ agent: "mimir", prompt: "Do X", model: "minimax/minimax-m3" },
|
|
172
172
|
{ agent: "odin", sessionID: "sess_parent", worktree: "/tmp" },
|
|
173
173
|
);
|
|
174
174
|
expect(result).not.toHaveProperty("error");
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plugins/bizar/tests/tools/opencode-runner.test.ts
|
|
3
|
+
*
|
|
4
|
+
* Unit tests for `buildOpencodeRunArgs` — the pure helper extracted
|
|
5
|
+
* from `plugins/bizar/src/opencode-runner.ts` so the argv passed to
|
|
6
|
+
* `Bun.spawn(["opencode", "run", ...])` can be asserted without
|
|
7
|
+
* spawning a real process.
|
|
8
|
+
*
|
|
9
|
+
* These tests pin the two regressions that motivated the extraction:
|
|
10
|
+
*
|
|
11
|
+
* 1. Bug 1 (HIGH): the `--agent` flag was missing from the args list,
|
|
12
|
+
* so `opencode run` silently fell back to the opencode default
|
|
13
|
+
* agent and broke session attribution.
|
|
14
|
+
*
|
|
15
|
+
* 2. Bug 2 (HIGH): the OpenRouter model IDs were renamed. Tests assert
|
|
16
|
+
* the new `minimax/minimax-m3` / `minimax/minimax-m2.7` format so
|
|
17
|
+
* a stale `openrouter/minimax-m3` reference is caught immediately.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect } from "bun:test";
|
|
21
|
+
import {
|
|
22
|
+
buildOpencodeRunArgs,
|
|
23
|
+
type SpawnAgentOptions,
|
|
24
|
+
} from "../../src/opencode-runner";
|
|
25
|
+
|
|
26
|
+
function baseOpts(overrides: Partial<SpawnAgentOptions> = {}): SpawnAgentOptions {
|
|
27
|
+
return {
|
|
28
|
+
agent: "thor",
|
|
29
|
+
prompt: "Investigate the auth module",
|
|
30
|
+
worktree: "/tmp/worktree",
|
|
31
|
+
logPath: "/tmp/worktree/.bgr/log.log",
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("buildOpencodeRunArgs — Bug 1 regression: --agent flag", () => {
|
|
37
|
+
it("argv contains the --agent flag followed by the agent name", () => {
|
|
38
|
+
const args = buildOpencodeRunArgs(baseOpts({ agent: "thor" }));
|
|
39
|
+
const i = args.indexOf("--agent");
|
|
40
|
+
expect(i).toBeGreaterThanOrEqual(0);
|
|
41
|
+
expect(args[i + 1]).toBe("thor");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("--agent value matches opts.agent exactly (no default fallback)", () => {
|
|
45
|
+
for (const agent of ["mimir", "thor", "tyr", "heimdall", "hermod"]) {
|
|
46
|
+
const args = buildOpencodeRunArgs(baseOpts({ agent }));
|
|
47
|
+
expect(args[args.indexOf("--agent") + 1]).toBe(agent);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("throws a clear error when opts.agent is empty", () => {
|
|
52
|
+
expect(() => buildOpencodeRunArgs(baseOpts({ agent: "" }))).toThrow(
|
|
53
|
+
/agent is required/,
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("buildOpencodeRunArgs — Bug 2 regression: migrated model ID", () => {
|
|
59
|
+
it("emits --model with the migrated 'minimax/minimax-m3' ID", () => {
|
|
60
|
+
const args = buildOpencodeRunArgs(
|
|
61
|
+
baseOpts({
|
|
62
|
+
model: { providerID: "minimax", modelID: "minimax-m3" },
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
const i = args.indexOf("--model");
|
|
66
|
+
expect(i).toBeGreaterThanOrEqual(0);
|
|
67
|
+
expect(args[i + 1]).toBe("minimax/minimax-m3");
|
|
68
|
+
// The full flag pair proves the migration target — the OLD ID would
|
|
69
|
+
// be 'openrouter/minimax-m3'; the NEW ID is 'minimax/minimax-m3'.
|
|
70
|
+
expect(args[i + 1]).not.toBe("openrouter/minimax-m3");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("emits --model with the migrated 'minimax/minimax-m2.7' ID", () => {
|
|
74
|
+
const args = buildOpencodeRunArgs(
|
|
75
|
+
baseOpts({
|
|
76
|
+
model: { providerID: "minimax", modelID: "minimax-m2.7" },
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
expect(args[args.indexOf("--model") + 1]).toBe("minimax/minimax-m2.7");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("omits --model entirely when opts.model is not provided", () => {
|
|
83
|
+
const args = buildOpencodeRunArgs(baseOpts({ model: undefined }));
|
|
84
|
+
expect(args).not.toContain("--model");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("buildOpencodeRunArgs — arg layout", () => {
|
|
89
|
+
it("starts with 'opencode' 'run'", () => {
|
|
90
|
+
const args = buildOpencodeRunArgs(baseOpts());
|
|
91
|
+
expect(args[0]).toBe("opencode");
|
|
92
|
+
expect(args[1]).toBe("run");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("appends prompt after the -- separator", () => {
|
|
96
|
+
const args = buildOpencodeRunArgs(baseOpts({ prompt: "do the thing" }));
|
|
97
|
+
const sep = args.indexOf("--");
|
|
98
|
+
expect(sep).toBeGreaterThanOrEqual(0);
|
|
99
|
+
expect(args[sep + 1]).toBe("do the thing");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("uses opts.title when provided, else defaults to bgr:<agent>:<ts>", () => {
|
|
103
|
+
const a = buildOpencodeRunArgs(baseOpts({ title: "custom title" }));
|
|
104
|
+
expect(a[a.indexOf("--title") + 1]).toBe("custom title");
|
|
105
|
+
|
|
106
|
+
const b = buildOpencodeRunArgs(baseOpts({ agent: "mimir" }));
|
|
107
|
+
expect(b[b.indexOf("--title") + 1]).toMatch(/^bgr:mimir:\d+$/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("wires --dir to opts.worktree and --log-level to INFO", () => {
|
|
111
|
+
const args = buildOpencodeRunArgs(baseOpts({ worktree: "/srv/repo" }));
|
|
112
|
+
expect(args[args.indexOf("--dir") + 1]).toBe("/srv/repo");
|
|
113
|
+
expect(args[args.indexOf("--log-level") + 1]).toBe("INFO");
|
|
114
|
+
});
|
|
115
|
+
});
|