@polderlabs/bizar 5.4.0 → 5.5.0

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 (99) hide show
  1. package/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js → EnvVarsSection-L413CQEI.js} +3 -3
  2. package/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js.map → EnvVarsSection-L413CQEI.js.map} +1 -1
  3. package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js → MobileChat-0kGQb1S6.js} +1 -1
  4. package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js.map → MobileChat-0kGQb1S6.js.map} +1 -1
  5. package/bizar-dash/dist/assets/MobileSettings-Cq1oFIdO.js +1 -0
  6. package/bizar-dash/dist/assets/{MobileSettings-CVW9VhEo.js.map → MobileSettings-Cq1oFIdO.js.map} +1 -1
  7. package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js → Toast-C6f1zFJ2.js} +1 -1
  8. package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js.map → Toast-C6f1zFJ2.js.map} +1 -1
  9. package/bizar-dash/dist/assets/{icons-OMEGx5KA.js → icons-BsWXWkEF.js} +121 -126
  10. package/bizar-dash/dist/assets/icons-BsWXWkEF.js.map +1 -0
  11. package/bizar-dash/dist/assets/main-BklYy02e.css +1 -0
  12. package/bizar-dash/dist/assets/main-CBTgYSeG.js +18 -0
  13. package/bizar-dash/dist/assets/main-CBTgYSeG.js.map +1 -0
  14. package/bizar-dash/dist/assets/mobile-CsaN4CxH.js +1 -0
  15. package/bizar-dash/dist/assets/{mobile-XN2P7YfM.js.map → mobile-CsaN4CxH.js.map} +1 -1
  16. package/bizar-dash/dist/assets/{mobile-layout-D6V-7WnA.css → mobile-layout-CJnZNLy3.css} +1 -1
  17. package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js +2 -0
  18. package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js.map +1 -0
  19. package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js → useSlashCommands-DGJcC9iQ.js} +2 -2
  20. package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js.map → useSlashCommands-DGJcC9iQ.js.map} +1 -1
  21. package/bizar-dash/dist/index.html +8 -8
  22. package/bizar-dash/dist/mobile.html +4 -4
  23. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  24. package/bizar-dash/src/server/backup-store.mjs +6 -1
  25. package/bizar-dash/src/server/bg-spawner.mjs +574 -0
  26. package/bizar-dash/src/server/memory-lightrag.mjs +242 -0
  27. package/bizar-dash/src/server/memory-store.mjs +43 -3
  28. package/bizar-dash/src/server/routes/background.mjs +143 -0
  29. package/bizar-dash/src/server/routes/lightrag.mjs +10 -0
  30. package/bizar-dash/src/server/routes/memory.mjs +8 -0
  31. package/bizar-dash/src/server/server.mjs +22 -0
  32. package/bizar-dash/src/web/App.tsx +3 -0
  33. package/bizar-dash/src/web/MobileApp.tsx +1 -10
  34. package/bizar-dash/src/web/components/InstallConfirmDialog.tsx +1 -1
  35. package/bizar-dash/src/web/components/SettingsNav.tsx +1 -10
  36. package/bizar-dash/src/web/lib/types.ts +39 -1
  37. package/bizar-dash/src/web/styles/main.css +43 -0
  38. package/bizar-dash/src/web/styles/mobile-layout.css +7 -5
  39. package/bizar-dash/src/web/styles/settings.css +46 -0
  40. package/bizar-dash/src/web/views/BackgroundAgents.tsx +399 -30
  41. package/bizar-dash/src/web/views/Settings.tsx +12 -12
  42. package/bizar-dash/src/web/views/SpawnAgentModal.tsx +221 -0
  43. package/bizar-dash/tests/BackgroundAgents.test.tsx +160 -0
  44. package/bizar-dash/tests/background-pause-resume.test.mjs +46 -0
  45. package/bizar-dash/tests/background-spawn-from-ui.test.mjs +36 -0
  46. package/bizar-dash/tests/background-steer.test.mjs +42 -0
  47. package/bizar-dash/tests/lightrag-startup-hook.test.mjs +206 -0
  48. package/bizar-dash/tests/memory-auto-reindex.test.mjs +196 -0
  49. package/bizar-dash/tests/memory-cli.test.mjs +1 -1
  50. package/bizar-dash/tests/memory-default-vault.test.mjs +90 -3
  51. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +131 -0
  52. package/bizar-dash/tests/mobile-layout.test.tsx +36 -0
  53. package/bizar-dash/tests/settings-layout.test.tsx +3 -3
  54. package/bizar-dash/tests/settings-nav.test.tsx +7 -5
  55. package/cli/bin.mjs +16 -0
  56. package/cli/commands/deploy/docker.mjs +2 -1
  57. package/cli/commands/lightrag.mjs +171 -0
  58. package/cli/commands/service.mjs +7 -0
  59. package/cli/commands/util.mjs +8 -1
  60. package/cli/init.mjs +1 -1
  61. package/cli/post-install-smoke.mjs +230 -0
  62. package/cli/provision.mjs +177 -0
  63. package/cli/provision.test.mjs +180 -0
  64. package/cli/service-controller.mjs +378 -28
  65. package/cli/service-controller.test.mjs +100 -0
  66. package/cli/service-env.mjs +139 -0
  67. package/cli/service.mjs +23 -0
  68. package/config/agents/_shared/AGENT_BASELINE.md +6 -6
  69. package/install.sh +87 -6
  70. package/package.json +1 -1
  71. package/plugins/bizar/index.ts +147 -0
  72. package/plugins/bizar/src/background-state.ts +84 -5
  73. package/plugins/bizar/src/background.ts +388 -10
  74. package/plugins/bizar/src/hooks/memory-inject.ts +247 -0
  75. package/plugins/bizar/src/hooks/memory-write-on-end.ts +188 -0
  76. package/plugins/bizar/src/opencode-runner.ts +87 -1
  77. package/plugins/bizar/src/tools/bg-pause.ts +77 -0
  78. package/plugins/bizar/src/tools/bg-report-progress.ts +101 -0
  79. package/plugins/bizar/src/tools/bg-resume.ts +72 -0
  80. package/plugins/bizar/src/tools/bg-send-message.ts +105 -0
  81. package/plugins/bizar/src/tools/bg-spawn.ts +13 -6
  82. package/plugins/bizar/src/tools/bg-status.ts +10 -0
  83. package/plugins/bizar/src/tools/memory-list.ts +135 -0
  84. package/plugins/bizar/src/tools/memory-read.ts +142 -0
  85. package/plugins/bizar/src/tools/memory-search.ts +228 -0
  86. package/plugins/bizar/src/tools/memory-write.ts +183 -0
  87. package/plugins/bizar/tests/memory-write-on-end.test.ts +92 -0
  88. package/plugins/bizar/tests/tools/bg-pause.test.ts +64 -0
  89. package/plugins/bizar/tests/tools/bg-report-progress.test.ts +82 -0
  90. package/plugins/bizar/tests/tools/bg-resume.test.ts +43 -0
  91. package/plugins/bizar/tests/tools/bg-send-message.test.ts +46 -0
  92. package/bizar-dash/dist/assets/MobileSettings-CVW9VhEo.js +0 -1
  93. package/bizar-dash/dist/assets/icons-OMEGx5KA.js.map +0 -1
  94. package/bizar-dash/dist/assets/main-CMAGZouj.js +0 -16
  95. package/bizar-dash/dist/assets/main-CMAGZouj.js.map +0 -1
  96. package/bizar-dash/dist/assets/main-O2tZVdHm.css +0 -1
  97. package/bizar-dash/dist/assets/mobile-XN2P7YfM.js +0 -1
  98. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js +0 -2
  99. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js.map +0 -1
@@ -0,0 +1,221 @@
1
+ // src/web/views/SpawnAgentModal.tsx — modal form to spawn a background
2
+ // agent directly from the dashboard. Calls POST /api/background,
3
+ // tracks the resulting instanceId, and on success closes the modal
4
+ // while the parent navigates to the new instance's detail view.
5
+ //
6
+ // Fields mirror the API body shape:
7
+ // agent (string, required) — agent name
8
+ // prompt (string, required) — initial prompt
9
+ // model (string, optional) — "providerID/modelID"
10
+ // timeoutMs (number, slider) — 1min..4h, default 5min
11
+ // persistent (boolean, toggle) — auto-restart on failure
12
+ // tags (string[], optional)
13
+ import { useState, type FormEvent } from 'react';
14
+ import { Plus, X } from 'lucide-react';
15
+ import { Button } from '../components/Button';
16
+ import { useToast } from '../components/Toast';
17
+ import { api } from '../lib/api';
18
+
19
+ type AgentOption = { name: string; description?: string };
20
+
21
+ type Props = {
22
+ open: boolean;
23
+ onClose: () => void;
24
+ onCreated?: (instanceId: string) => void;
25
+ /** Available agents for the dropdown. Defaults to a small static set. */
26
+ agents?: AgentOption[];
27
+ };
28
+
29
+ const DEFAULT_AGENTS: AgentOption[] = [
30
+ { name: 'mimir', description: 'Deep research & codebase exploration' },
31
+ { name: 'frigg', description: 'Read-only codebase Q&A' },
32
+ { name: 'thor', description: 'Moderate-complexity implementation' },
33
+ { name: 'tyr', description: 'Complex feature implementation' },
34
+ { name: 'heimdall', description: 'Quick edits & simple tasks' },
35
+ { name: 'odin', description: 'Primary router agent' },
36
+ ];
37
+
38
+ const TIMEOUT_PRESETS = [
39
+ { label: '1 min', value: 60_000 },
40
+ { label: '5 min', value: 300_000 },
41
+ { label: '15 min', value: 900_000 },
42
+ { label: '30 min', value: 1_800_000 },
43
+ { label: '1 hr', value: 3_600_000 },
44
+ { label: '4 hr', value: 14_400_000 },
45
+ ];
46
+
47
+ export function SpawnAgentModal({ open, onClose, onCreated, agents = DEFAULT_AGENTS }: Props) {
48
+ const toast = useToast();
49
+ const [agent, setAgent] = useState<string>(agents[0]?.name || 'mimir');
50
+ const [prompt, setPrompt] = useState<string>('');
51
+ const [model, setModel] = useState<string>('');
52
+ const [timeoutMs, setTimeoutMs] = useState<number>(300_000);
53
+ const [persistent, setPersistent] = useState<boolean>(false);
54
+ const [tagsRaw, setTagsRaw] = useState<string>('');
55
+ const [submitting, setSubmitting] = useState<boolean>(false);
56
+ const [error, setError] = useState<string | null>(null);
57
+
58
+ if (!open) return null;
59
+
60
+ const onSubmit = async (ev: FormEvent<HTMLFormElement>) => {
61
+ ev.preventDefault();
62
+ setError(null);
63
+ if (!prompt.trim()) {
64
+ setError('Prompt is required.');
65
+ return;
66
+ }
67
+ setSubmitting(true);
68
+ try {
69
+ const tags = tagsRaw
70
+ .split(',')
71
+ .map((t) => t.trim())
72
+ .filter((t) => t.length > 0);
73
+ const body = {
74
+ agent,
75
+ prompt,
76
+ ...(model.trim() ? { model: model.trim() } : {}),
77
+ timeoutMs,
78
+ persistent,
79
+ maxRestarts: 3,
80
+ ...(tags.length > 0 ? { tags } : {}),
81
+ };
82
+ const res = await api.post<{
83
+ instanceId: string;
84
+ status: string;
85
+ sessionId?: string;
86
+ error?: string;
87
+ message?: string;
88
+ }>('/background', body);
89
+ if (res?.error) {
90
+ throw new Error(res.message || res.error);
91
+ }
92
+ toast.success(`Spawned ${agent} (${res.instanceId.slice(0, 14)}…)`);
93
+ onCreated?.(res.instanceId);
94
+ // Reset the form for next time.
95
+ setPrompt('');
96
+ setModel('');
97
+ setPersistent(false);
98
+ setTagsRaw('');
99
+ onClose();
100
+ } catch (err) {
101
+ setError((err as Error)?.message || 'Failed to spawn.');
102
+ } finally {
103
+ setSubmitting(false);
104
+ }
105
+ };
106
+
107
+ return (
108
+ <div className="modal-backdrop" role="dialog" aria-modal="true">
109
+ <div className="modal-card spawn-modal">
110
+ <div className="spawn-modal-header">
111
+ <h3>
112
+ <Plus size={18} /> Spawn background agent
113
+ </h3>
114
+ <Button variant="ghost" size="sm" onClick={onClose} title="Close">
115
+ <X size={14} />
116
+ </Button>
117
+ </div>
118
+ <form onSubmit={onSubmit} className="spawn-modal-body">
119
+ <label className="field">
120
+ <span className="field-label">Agent</span>
121
+ <select
122
+ className="field-input"
123
+ value={agent}
124
+ onChange={(e) => setAgent(e.target.value)}
125
+ required
126
+ >
127
+ {agents.map((a) => (
128
+ <option key={a.name} value={a.name}>
129
+ {a.name}{a.description ? ` — ${a.description}` : ''}
130
+ </option>
131
+ ))}
132
+ </select>
133
+ </label>
134
+
135
+ <label className="field">
136
+ <span className="field-label">Initial prompt</span>
137
+ <textarea
138
+ className="field-input spawn-modal-textarea"
139
+ rows={5}
140
+ value={prompt}
141
+ onChange={(e) => setPrompt(e.target.value)}
142
+ placeholder="What do you want this agent to do?"
143
+ required
144
+ />
145
+ </label>
146
+
147
+ <label className="field">
148
+ <span className="field-label">Model (optional)</span>
149
+ <input
150
+ type="text"
151
+ className="field-input"
152
+ value={model}
153
+ onChange={(e) => setModel(e.target.value)}
154
+ placeholder="providerID/modelID (leave blank for agent default)"
155
+ />
156
+ </label>
157
+
158
+ <div className="field">
159
+ <span className="field-label">Timeout</span>
160
+ <div className="spawn-modal-timeouts">
161
+ {TIMEOUT_PRESETS.map((p) => (
162
+ <button
163
+ key={p.value}
164
+ type="button"
165
+ className={
166
+ 'spawn-modal-timeout-pill' +
167
+ (p.value === timeoutMs ? ' spawn-modal-timeout-pill-active' : '')
168
+ }
169
+ onClick={() => setTimeoutMs(p.value)}
170
+ >
171
+ {p.label}
172
+ </button>
173
+ ))}
174
+ </div>
175
+ <input
176
+ type="range"
177
+ min={60_000}
178
+ max={14_400_000}
179
+ step={60_000}
180
+ value={timeoutMs}
181
+ onChange={(e) => setTimeoutMs(Number(e.target.value))}
182
+ className="spawn-modal-slider"
183
+ />
184
+ <span className="muted">{(timeoutMs / 60_000).toFixed(0)} min</span>
185
+ </div>
186
+
187
+ <label className="field-checkbox">
188
+ <input
189
+ type="checkbox"
190
+ checked={persistent}
191
+ onChange={(e) => setPersistent(e.target.checked)}
192
+ />
193
+ <span>Persistent — auto-restart on terminal failure (max 3 attempts)</span>
194
+ </label>
195
+
196
+ <label className="field">
197
+ <span className="field-label">Tags (optional, comma-separated)</span>
198
+ <input
199
+ type="text"
200
+ className="field-input"
201
+ value={tagsRaw}
202
+ onChange={(e) => setTagsRaw(e.target.value)}
203
+ placeholder="e.g. refactor, urgent"
204
+ />
205
+ </label>
206
+
207
+ {error && <div className="spawn-modal-error">{error}</div>}
208
+
209
+ <div className="spawn-modal-actions">
210
+ <Button variant="ghost" size="sm" type="button" onClick={onClose}>
211
+ Cancel
212
+ </Button>
213
+ <Button variant="primary" size="sm" type="submit" disabled={submitting}>
214
+ {submitting ? 'Spawning…' : 'Spawn agent'}
215
+ </Button>
216
+ </div>
217
+ </form>
218
+ </div>
219
+ </div>
220
+ );
221
+ }
@@ -0,0 +1,160 @@
1
+ // tests/BackgroundAgents.test.tsx — v5.x component tests for the
2
+ // rewired BackgroundAgents view and SpawnAgentModal. Uses
3
+ // @testing-library/react and the dashboard's vitest setup.
4
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
5
+ import { render, screen, waitFor } from '@testing-library/react';
6
+ import userEvent from '@testing-library/user-event';
7
+ import { BackgroundAgents } from '../src/web/views/BackgroundAgents';
8
+ import { ToastProvider } from '../src/web/components/Toast';
9
+
10
+ // --- API mock ------------------------------------------------------------
11
+
12
+ const mockApiGet = vi.fn();
13
+ const mockApiPost = vi.fn();
14
+
15
+ vi.mock('../src/web/lib/api', () => ({
16
+ api: {
17
+ get: (...args: unknown[]) => mockApiGet(...args),
18
+ post: (...args: unknown[]) => mockApiPost(...args),
19
+ },
20
+ }));
21
+
22
+ vi.mock('../src/web/lib/ws', () => ({
23
+ Ws: class {
24
+ constructor() {}
25
+ on() { return () => {}; }
26
+ onStatus() { return () => {}; }
27
+ close() {}
28
+ },
29
+ }));
30
+
31
+ // --- Helpers -------------------------------------------------------------
32
+
33
+ const dummySnapshot = {} as any;
34
+ const dummySettings = {} as any;
35
+
36
+ function wrap(node: React.ReactNode) {
37
+ return <ToastProvider>{node}</ToastProvider>;
38
+ }
39
+
40
+ beforeEach(() => {
41
+ mockApiGet.mockReset();
42
+ mockApiPost.mockReset();
43
+ // Default: list returns empty.
44
+ mockApiGet.mockImplementation(async (path: string) => {
45
+ if (path === '/background') return { instances: [], status: { dir: '/tmp', exists: false, count: 0 } };
46
+ if (path.includes('/tool-calls')) return { toolCalls: [] };
47
+ if (path.includes('/output')) return { output: '', available: false };
48
+ if (path.includes('/tmux')) return { session: 'bgr_x', exists: false, attachCommand: 'tmux attach -t bgr_x', attachUrl: 'tmux://bgr_x' };
49
+ return {};
50
+ });
51
+ });
52
+
53
+ // --- Tests ---------------------------------------------------------------
54
+
55
+ describe('BackgroundAgents — v5.x integration', () => {
56
+ it('renders empty state with a Spawn action', async () => {
57
+ render(wrap(<BackgroundAgents snapshot={dummySnapshot} settings={dummySettings} activeTab="background" setActiveTab={() => {}} refreshSnapshot={async () => {}} />));
58
+ await waitFor(() =>
59
+ expect(screen.getByText(/No active background agents/i)).toBeInTheDocument(),
60
+ );
61
+ expect(screen.getAllByRole('button', { name: /Spawn/i }).length).toBeGreaterThan(0);
62
+ });
63
+
64
+ it('renders an active card with pause + steer buttons', async () => {
65
+ mockApiGet.mockImplementation(async (path: string) => {
66
+ if (path === '/background') {
67
+ return {
68
+ instances: [
69
+ {
70
+ instanceId: 'bgr_running_x',
71
+ status: 'running',
72
+ agent: 'mimir',
73
+ prompt: 'say hi',
74
+ startedAt: Date.now() - 5000,
75
+ toolCallCount: 0,
76
+ tmuxSession: 'bgr_running_x',
77
+ tmuxActive: false,
78
+ },
79
+ ],
80
+ status: { dir: '/tmp', exists: true, count: 1 },
81
+ };
82
+ }
83
+ return {};
84
+ });
85
+ render(wrap(<BackgroundAgents snapshot={dummySnapshot} settings={dummySettings} activeTab="background" setActiveTab={() => {}} refreshSnapshot={async () => {}} />));
86
+ await waitFor(() => expect(screen.getByText('mimir')).toBeInTheDocument());
87
+ expect(screen.getByRole('button', { name: /Pause/i })).toBeInTheDocument();
88
+ expect(screen.getByRole('button', { name: /Steer/i })).toBeInTheDocument();
89
+ expect(screen.getByRole('button', { name: /Kill/i })).toBeInTheDocument();
90
+ });
91
+
92
+ it('renders a paused card with resume instead of pause', async () => {
93
+ mockApiGet.mockImplementation(async (path: string) => {
94
+ if (path === '/background') {
95
+ return {
96
+ instances: [
97
+ {
98
+ instanceId: 'bgr_paused_x',
99
+ status: 'paused',
100
+ agent: 'mimir',
101
+ prompt: 'paused run',
102
+ startedAt: Date.now() - 5000,
103
+ toolCallCount: 0,
104
+ },
105
+ ],
106
+ status: { dir: '/tmp', exists: true, count: 1 },
107
+ };
108
+ }
109
+ return {};
110
+ });
111
+ render(wrap(<BackgroundAgents snapshot={dummySnapshot} settings={dummySettings} activeTab="background" setActiveTab={() => {}} refreshSnapshot={async () => {}} />));
112
+ await waitFor(() => expect(screen.getByRole('button', { name: /Resume/i })).toBeInTheDocument());
113
+ expect(screen.queryByRole('button', { name: /Pause/i })).not.toBeInTheDocument();
114
+ });
115
+
116
+ it('progress bar shows when instance.progress is set', async () => {
117
+ mockApiGet.mockImplementation(async (path: string) => {
118
+ if (path === '/background') {
119
+ return {
120
+ instances: [
121
+ {
122
+ instanceId: 'bgr_progress_x',
123
+ status: 'running',
124
+ agent: 'mimir',
125
+ prompt: 'step',
126
+ startedAt: Date.now(),
127
+ toolCallCount: 1,
128
+ progress: 42,
129
+ progressMessage: 'halfway through',
130
+ },
131
+ ],
132
+ status: { dir: '/tmp', exists: true, count: 1 },
133
+ };
134
+ }
135
+ return {};
136
+ });
137
+ render(wrap(<BackgroundAgents snapshot={dummySnapshot} settings={dummySettings} activeTab="background" setActiveTab={() => {}} refreshSnapshot={async () => {}} />));
138
+ await waitFor(() => expect(screen.getByText(/42%/)).toBeInTheDocument());
139
+ expect(screen.getByText(/halfway through/i)).toBeInTheDocument();
140
+ });
141
+
142
+ it('pause button calls pause endpoint', async () => {
143
+ mockApiGet.mockImplementation(async (path: string) => {
144
+ if (path === '/background') {
145
+ return {
146
+ instances: [{ instanceId: 'bgr_x', status: 'running', agent: 'mimir', prompt: 'p', startedAt: Date.now(), toolCallCount: 0 }],
147
+ status: { dir: '/tmp', exists: true, count: 1 },
148
+ };
149
+ }
150
+ return {};
151
+ });
152
+ mockApiPost.mockResolvedValue({ ok: true });
153
+ render(wrap(<BackgroundAgents snapshot={dummySnapshot} settings={dummySettings} activeTab="background" setActiveTab={() => {}} refreshSnapshot={async () => {}} />));
154
+ const btn = await waitFor(() => screen.getByRole('button', { name: /Pause/i }));
155
+ await userEvent.click(btn);
156
+ await waitFor(() => {
157
+ expect(mockApiPost).toHaveBeenCalledWith('/background/bgr_x/pause', {});
158
+ });
159
+ });
160
+ });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * tests/background-pause-resume.test.mjs
3
+ *
4
+ * v5.x — Tests for POST /api/background/:id/pause and .../resume.
5
+ *
6
+ * Validates the spawner's pure-logic surface WITHOUT spawning a real
7
+ * opencode subprocess (which would block on `opencode run` exiting).
8
+ * The pause/resume registry helpers are tested via error paths:
9
+ * - unknown instance → "instance_not_tracked"
10
+ * - paused state survives round-trip after a real spawn (skipped — see notes)
11
+ */
12
+
13
+ import { test } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+
16
+ import {
17
+ pauseBgAgent,
18
+ resumeBgAgent,
19
+ status,
20
+ configureSpawner,
21
+ } from '../src/server/bg-spawner.mjs';
22
+
23
+ test('configureSpawner registers a broadcast hook without throwing', () => {
24
+ let received = null;
25
+ configureSpawner({ broadcast: (msg) => { received = msg; } });
26
+ assert.equal(typeof received, 'object');
27
+ });
28
+
29
+ test('pauseBgAgent on unknown instance returns instance_not_tracked', () => {
30
+ const r = pauseBgAgent('bgr_definitely_missing_id');
31
+ assert.equal(r.ok, false);
32
+ assert.equal(r.error, 'instance_not_tracked');
33
+ });
34
+
35
+ test('resumeBgAgent on unknown instance returns instance_not_tracked', () => {
36
+ const r = resumeBgAgent('bgr_definitely_missing_id');
37
+ assert.equal(r.ok, false);
38
+ assert.equal(r.error, 'instance_not_tracked');
39
+ });
40
+
41
+ test('status() returns count + ids diagnostics without throwing', () => {
42
+ const s = status();
43
+ assert.equal(typeof s.count, 'number');
44
+ assert.ok(Array.isArray(s.ids));
45
+ assert.ok(s.count >= 0);
46
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * tests/background-spawn-from-ui.test.mjs
3
+ *
4
+ * v5.x — Validates the spawner's input-validation surface only —
5
+ * doesn't actually launch `opencode run` (that would block).
6
+ *
7
+ * The full happy-path test lives in a manual run-script (see
8
+ * scripts/bg-spawn-smoke.mjs, future work) because the dashboard
9
+ * sandbox can't guarantee opencode is installed + auth'd.
10
+ */
11
+
12
+ import { test } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+
15
+ import { spawnBgAgent } from '../src/server/bg-spawner.mjs';
16
+
17
+ test('spawnBgAgent rejects empty args', async () => {
18
+ const r = await spawnBgAgent({});
19
+ assert.equal(r.error, 'missing_required_fields');
20
+ assert.equal(r.instanceId, '');
21
+ });
22
+
23
+ test('spawnBgAgent rejects missing prompt', async () => {
24
+ const r = await spawnBgAgent({ agent: 'mimir', worktree: '/tmp' });
25
+ assert.equal(r.error, 'missing_required_fields');
26
+ });
27
+
28
+ test('spawnBgAgent rejects missing agent', async () => {
29
+ const r = await spawnBgAgent({ prompt: 'hi', worktree: '/tmp' });
30
+ assert.equal(r.error, 'missing_required_fields');
31
+ });
32
+
33
+ test('spawnBgAgent rejects missing worktree', async () => {
34
+ const r = await spawnBgAgent({ agent: 'mimir', prompt: 'hi' });
35
+ assert.equal(r.error, 'missing_required_fields');
36
+ });
@@ -0,0 +1,42 @@
1
+ /**
2
+ * tests/background-steer.test.mjs
3
+ *
4
+ * v5.x — Tests for the steer-broker helper. Validates that the
5
+ * helper accepts a non-empty message and rejects empty input. We do
6
+ * NOT exercise the kill+respawn path here because it requires a real
7
+ * `opencode` binary on PATH; see background-spawn-from-ui.test.mjs
8
+ * for that path.
9
+ */
10
+
11
+ import { test } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { mkdtempSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { tmpdir } from 'node:os';
16
+
17
+ import { steerBgAgent } from '../src/server/bg-spawner.mjs';
18
+
19
+ test('steerBgAgent rejects empty message', async () => {
20
+ const r = await steerBgAgent('bgr_nope', '');
21
+ assert.equal(r.ok, false);
22
+ assert.equal(r.error, 'message_empty');
23
+ });
24
+
25
+ test('steerBgAgent rejects whitespace-only message', async () => {
26
+ const r = await steerBgAgent('bgr_nope', ' ');
27
+ assert.equal(r.ok, false);
28
+ assert.equal(r.error, 'message_empty');
29
+ });
30
+
31
+ test('steerBgAgent with missing instance returns not_found', async () => {
32
+ const tmp = mkdtempSync(join(tmpdir(), 'bgspr-'));
33
+ const originalHome = process.env.HOME;
34
+ process.env.HOME = tmp;
35
+ try {
36
+ const r = await steerBgAgent('bgr_definitely_missing', 'go faster');
37
+ assert.equal(r.ok, false);
38
+ assert.match(r.error || '', /instance_not_found|file/);
39
+ } finally {
40
+ process.env.HOME = originalHome;
41
+ }
42
+ });