@polderlabs/bizar 4.2.4 → 4.4.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.
- package/bizar-dash/src/server/api.mjs +2 -0
- package/bizar-dash/src/server/opencode-sdk.mjs +72 -0
- package/bizar-dash/src/server/routes/background.mjs +92 -0
- package/bizar-dash/src/server/routes/chat.mjs +300 -123
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +452 -0
- package/bizar-dash/src/server/routes/tasks.mjs +59 -1
- package/bizar-dash/src/server/serve-info.mjs +124 -1
- package/bizar-dash/src/server/task-delegator.mjs +154 -8
- package/bizar-dash/src/server/tasks-store.mjs +50 -2
- package/bizar-dash/src/web/components/background/AttachButton.tsx +96 -0
- package/bizar-dash/src/web/components/background/TmuxAttachCard.tsx +122 -0
- package/bizar-dash/src/web/components/chat/AgentNode.tsx +127 -0
- package/bizar-dash/src/web/components/chat/AgentTree.tsx +80 -0
- package/bizar-dash/src/web/components/chat/ChatComposer.tsx +76 -0
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +144 -0
- package/bizar-dash/src/web/components/chat/ChatRail.tsx +387 -0
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +28 -7
- package/bizar-dash/src/web/components/chat/JumpToLatest.tsx +58 -0
- package/bizar-dash/src/web/components/chat/MessageBlock.tsx +260 -0
- package/bizar-dash/src/web/components/chat/SessionList.tsx +15 -5
- package/bizar-dash/src/web/components/chat/SessionRowMenu.tsx +206 -0
- package/bizar-dash/src/web/components/chat/StreamingIndicator.tsx +33 -5
- package/bizar-dash/src/web/components/chat/_legacy.ts +30 -0
- package/bizar-dash/src/web/components/chat/index.ts +11 -0
- package/bizar-dash/src/web/components/chat/useChat.ts +426 -62
- package/bizar-dash/src/web/components/tasks/BacklogPanel.css +109 -0
- package/bizar-dash/src/web/components/tasks/BacklogPanel.tsx +209 -0
- package/bizar-dash/src/web/lib/types.ts +1 -0
- package/bizar-dash/src/web/mobile/views/MobileChat.tsx +7 -5
- package/bizar-dash/src/web/styles/chat.css +1536 -133
- package/bizar-dash/src/web/views/BackgroundAgents.tsx +3 -0
- package/bizar-dash/src/web/views/Chat.tsx +147 -55
- package/bizar-dash/src/web/views/Tasks.tsx +23 -1
- package/bizar-dash/tests/opencode-sessions-detail.test.mjs +273 -0
- package/cli/bg.mjs +94 -71
- package/cli/bin.mjs +19 -7
- package/cli/service-controller.mjs +587 -0
- package/cli/service-controller.test.mjs +92 -0
- package/cli/service.mjs +162 -14
- package/config/agents/baldr.md +2 -0
- package/config/agents/browser-harness.md +2 -0
- package/config/agents/forseti.md +2 -0
- package/config/agents/frigg.md +2 -0
- package/config/agents/heimdall.md +2 -0
- package/config/agents/hermod.md +2 -0
- package/config/agents/mimir.md +2 -0
- package/config/agents/odin.md +2 -0
- package/config/agents/quick.md +2 -0
- package/config/agents/semble-search.md +2 -0
- package/config/agents/thor.md +2 -0
- package/config/agents/tyr.md +2 -0
- package/config/agents/vidarr.md +2 -0
- package/config/agents/vor.md +2 -0
- package/config/opencode.json.template +1 -0
- package/install.sh +448 -787
- package/package.json +2 -2
- package/packages/sdk/package.json +20 -0
- package/packages/sdk/src/client.ts +5 -0
- package/packages/sdk/src/errors.ts +11 -2
- package/packages/sdk/src/index.ts +19 -0
- package/packages/sdk/src/opencode-events.ts +134 -0
- package/packages/sdk/src/opencode-types.ts +66 -0
- package/packages/sdk/src/opencode.ts +335 -0
|
@@ -21,6 +21,7 @@ import { useModal } from '../components/Modal';
|
|
|
21
21
|
import { useToast } from '../components/Toast';
|
|
22
22
|
import { BgStatusBadge } from '../components/BgStatusBadge';
|
|
23
23
|
import { openKillConfirmDialog } from '../components/KillConfirmDialog';
|
|
24
|
+
import { TmuxAttachCard } from '../components/background/TmuxAttachCard';
|
|
24
25
|
import { api } from '../lib/api';
|
|
25
26
|
import { Ws } from '../lib/ws';
|
|
26
27
|
import { cn, formatTime, truncate } from '../lib/utils';
|
|
@@ -283,6 +284,8 @@ function BackgroundAgentCard({
|
|
|
283
284
|
<Trash2 size={14} /> Kill
|
|
284
285
|
</Button>
|
|
285
286
|
</div>
|
|
287
|
+
|
|
288
|
+
<TmuxAttachCard instance={inst} />
|
|
286
289
|
</Card>
|
|
287
290
|
);
|
|
288
291
|
}
|
|
@@ -1,13 +1,22 @@
|
|
|
1
|
-
// src/views/Chat.tsx — Gemini-inspired dark chat orchestrator.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// src/views/Chat.tsx — Gemini-inspired dark chat orchestrator (v3.22).
|
|
2
|
+
//
|
|
3
|
+
// v3.22 — redesigned layout: 3-column grid (rail / thread / info).
|
|
4
|
+
// The legacy `.chat-shell / .chat-body / .chat-sessions / .chat-main /
|
|
5
|
+
// .chat-info` wrapper classes are still available for MobileChat (which
|
|
6
|
+
// uses its own view); this file uses the new `.chat-page` grid.
|
|
7
|
+
//
|
|
8
|
+
// The `chat-thread` section is a vertical grid (head / scroll / composer).
|
|
9
|
+
// The scrollable message list lives inside the legacy `ChatThread`
|
|
10
|
+
// component (still wrapped in its own `.chat-thread` div with className
|
|
11
|
+
// `legacy` to opt into the original styles — see chat.css).
|
|
4
12
|
|
|
5
|
-
import { useEffect, useRef, useState } from 'react';
|
|
13
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
6
14
|
import { ChatTopBar } from '../components/chat/ChatTopBar';
|
|
7
|
-
import {
|
|
15
|
+
import { ChatRail } from '../components/chat/ChatRail';
|
|
8
16
|
import { ChatThread } from '../components/chat/ChatThread';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
17
|
+
import { ChatComposer } from '../components/chat/ChatComposer';
|
|
18
|
+
import { ChatInfoPanel } from '../components/chat/ChatInfoPanel';
|
|
19
|
+
import { JumpToLatest } from '../components/chat/JumpToLatest';
|
|
11
20
|
import { useChat } from '../components/chat/useChat';
|
|
12
21
|
import { useSlashCommands } from '../components/chat/useSlashCommands';
|
|
13
22
|
import { useToast } from '../components/Toast';
|
|
@@ -28,7 +37,6 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
|
|
|
28
37
|
const toast = useToast();
|
|
29
38
|
const modal = useModal();
|
|
30
39
|
|
|
31
|
-
// ── Delegate to useChat: messages, sessions, send, polling ──────────────
|
|
32
40
|
const chat = useChat(snapshot, settings, initialTaskId ?? '');
|
|
33
41
|
|
|
34
42
|
// ── Composer state (local) ───────────────────────────────────────────────
|
|
@@ -39,12 +47,13 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
|
|
|
39
47
|
const [creating, setCreating] = useState(false);
|
|
40
48
|
|
|
41
49
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
42
|
-
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
43
50
|
|
|
44
51
|
// ── Slash commands ───────────────────────────────────────────────────────
|
|
45
52
|
const { allCommands, suggestions, setQuery } = useSlashCommands(snapshot);
|
|
46
53
|
|
|
47
|
-
useEffect(() => {
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
setQuery(text);
|
|
56
|
+
}, [text, setQuery]);
|
|
48
57
|
|
|
49
58
|
// ── File attach ──────────────────────────────────────────────────────────
|
|
50
59
|
const onAttach = () => fileInputRef.current?.click();
|
|
@@ -68,6 +77,7 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
|
|
|
68
77
|
setText('');
|
|
69
78
|
setQuery('');
|
|
70
79
|
chat.onSend(msg, agent, model, attachments);
|
|
80
|
+
chat.jumpToLatest();
|
|
71
81
|
};
|
|
72
82
|
|
|
73
83
|
// ── Create session ───────────────────────────────────────────────────────
|
|
@@ -96,7 +106,9 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
|
|
|
96
106
|
children: <p style={{ margin: 0 }}>This action cannot be undone.</p>,
|
|
97
107
|
footer: (
|
|
98
108
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
99
|
-
<Button variant="secondary" size="sm" onClick={() => modal.close()}>
|
|
109
|
+
<Button variant="secondary" size="sm" onClick={() => modal.close()}>
|
|
110
|
+
Cancel
|
|
111
|
+
</Button>
|
|
100
112
|
<Button
|
|
101
113
|
variant="danger"
|
|
102
114
|
size="sm"
|
|
@@ -112,7 +124,42 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
|
|
|
112
124
|
});
|
|
113
125
|
};
|
|
114
126
|
|
|
115
|
-
// ──
|
|
127
|
+
// ── Derived: sessions with display state overlaid ────────────────────────
|
|
128
|
+
const displaySessions = useMemo(
|
|
129
|
+
() => chat.sessions.map((s) => chat.getSessionDisplay(s)),
|
|
130
|
+
[chat.sessions, chat.getSessionDisplay],
|
|
131
|
+
);
|
|
132
|
+
const displayOpencodeSessions = useMemo(
|
|
133
|
+
() => chat.opencodeSessions.map((s) => chat.getSessionDisplay(s)),
|
|
134
|
+
[chat.opencodeSessions, chat.getSessionDisplay],
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
// ── Current session for the thread head subtitle ────────────────────────
|
|
138
|
+
const activeSessionDisplay = useMemo(() => {
|
|
139
|
+
const id = chat.activeSource === 'opencode'
|
|
140
|
+
? chat.activeOpencodeSessionId ?? ''
|
|
141
|
+
: chat.sessionId;
|
|
142
|
+
if (!id) return null;
|
|
143
|
+
return (
|
|
144
|
+
displaySessions.find((s) => s.id === id) ??
|
|
145
|
+
displayOpencodeSessions.find((s) => s.id === id) ??
|
|
146
|
+
null
|
|
147
|
+
);
|
|
148
|
+
}, [
|
|
149
|
+
chat.activeSource,
|
|
150
|
+
chat.activeOpencodeSessionId,
|
|
151
|
+
chat.sessionId,
|
|
152
|
+
displaySessions,
|
|
153
|
+
displayOpencodeSessions,
|
|
154
|
+
]);
|
|
155
|
+
|
|
156
|
+
const threadSubtitle = (() => {
|
|
157
|
+
const base = `${agent || 'Odin'} · ${snapshot.activeProject?.name ?? 'no project'}`;
|
|
158
|
+
const state = activeSessionDisplay?.state ?? 'idle';
|
|
159
|
+
if (state === 'streaming') return `Replying · ${base}`;
|
|
160
|
+
if (state === 'awaiting') return `Your turn · ${base}`;
|
|
161
|
+
return `${base} · idle`;
|
|
162
|
+
})();
|
|
116
163
|
|
|
117
164
|
return (
|
|
118
165
|
<div className="chat-shell">
|
|
@@ -125,33 +172,71 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
|
|
|
125
172
|
onToggleInfo={() => {}}
|
|
126
173
|
onOpenOverview={() => setActiveTab?.('overview')}
|
|
127
174
|
/>
|
|
128
|
-
<div className="chat-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
175
|
+
<div className="chat-page">
|
|
176
|
+
{/* ── rail ──────────────────────────────────────────────────────── */}
|
|
177
|
+
<ChatRail
|
|
178
|
+
sessions={displaySessions}
|
|
179
|
+
opencodeSessions={displayOpencodeSessions}
|
|
180
|
+
activeSessionId={chat.sessionId}
|
|
181
|
+
activeOpencodeSessionId={chat.activeOpencodeSessionId}
|
|
182
|
+
activeProject={snapshot.activeProject}
|
|
183
|
+
creating={creating}
|
|
184
|
+
onCreateSession={handleCreateSession}
|
|
185
|
+
onSelectSession={chat.selectBizarSession}
|
|
186
|
+
onSelectOpencodeSession={(s) => chat.loadOpencodeSession(s.id)}
|
|
187
|
+
onRenameSession={(id, title) => chat.renameSession(id, title)}
|
|
188
|
+
onDeleteSession={(id) => chat.deleteSession(id)}
|
|
189
|
+
/>
|
|
190
|
+
|
|
191
|
+
{/* ── thread ───────────────────────────────────────────────────── */}
|
|
192
|
+
<section className="chat-thread-section">
|
|
193
|
+
<div className="chat-thread-head">
|
|
194
|
+
<div>
|
|
195
|
+
<div className="chat-thread-title">
|
|
196
|
+
{activeSessionDisplay?.title ?? chat.sessionId ?? 'New chat'}
|
|
197
|
+
</div>
|
|
198
|
+
<div
|
|
199
|
+
className={`chat-thread-sub chat-muted state-${activeSessionDisplay?.state ?? 'idle'}`}
|
|
200
|
+
>
|
|
201
|
+
<span className="chat-thread-dot" />
|
|
202
|
+
{threadSubtitle}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
<div className="chat-thread-actions">
|
|
206
|
+
<button className="btn btn-ghost" title="Rename" type="button">
|
|
207
|
+
<span className="mono">rename</span>
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<div className="chat-thread-scroll" ref={chat.listRef} onScroll={chat.handleScroll}>
|
|
213
|
+
<ChatThread
|
|
214
|
+
messages={chat.activeSource === 'opencode' ? chat.opencodeMessages : chat.bizarMessages}
|
|
215
|
+
loading={chat.loading}
|
|
216
|
+
activeProject={snapshot.activeProject}
|
|
217
|
+
sessionId={
|
|
218
|
+
chat.activeSource === 'opencode'
|
|
219
|
+
? (chat.activeOpencodeSessionId ?? chat.sessionId)
|
|
220
|
+
: chat.sessionId
|
|
221
|
+
}
|
|
222
|
+
pinned={chat.pinned}
|
|
223
|
+
onPickSuggestion={(t) => setText(t)}
|
|
224
|
+
onCopy={(m) => chat.copyMessage(m as Parameters<typeof chat.copyMessage>[0])}
|
|
225
|
+
onDelete={handleDelete}
|
|
226
|
+
onTogglePin={chat.togglePin}
|
|
227
|
+
onRegenerate={chat.onRegenerate}
|
|
228
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{!chat.stickToBottom && (
|
|
232
|
+
<JumpToLatest
|
|
233
|
+
streaming={activeSessionDisplay?.state === 'streaming'}
|
|
234
|
+
newMessageCount={chat.newMessageCount}
|
|
235
|
+
onClick={chat.jumpToLatest}
|
|
236
|
+
/>
|
|
237
|
+
)}
|
|
238
|
+
|
|
239
|
+
<ChatComposer
|
|
155
240
|
agent={agent}
|
|
156
241
|
setAgent={setAgent}
|
|
157
242
|
model={model}
|
|
@@ -166,24 +251,31 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
|
|
|
166
251
|
onPickSuggestion={(cmd) => setText(`${cmd.split(' ')[0]} `)}
|
|
167
252
|
agents={snapshot.agents || []}
|
|
168
253
|
onAttach={onAttach}
|
|
169
|
-
sessionsOpen={true}
|
|
170
|
-
infoOpen={true}
|
|
171
254
|
/>
|
|
172
|
-
<input
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
messages={chat.messages}
|
|
179
|
-
pinned={chat.pinned}
|
|
180
|
-
agent={agent}
|
|
181
|
-
model={model}
|
|
182
|
-
agents={snapshot.agents || []}
|
|
183
|
-
mcps={snapshot.mcps || []}
|
|
184
|
-
allCommands={allCommands}
|
|
255
|
+
<input
|
|
256
|
+
ref={fileInputRef}
|
|
257
|
+
type="file"
|
|
258
|
+
multiple
|
|
259
|
+
style={{ display: 'none' }}
|
|
260
|
+
onChange={onFiles}
|
|
185
261
|
/>
|
|
186
|
-
</
|
|
262
|
+
</section>
|
|
263
|
+
|
|
264
|
+
{/* ── info panel ──────────────────────────────────────────────── */}
|
|
265
|
+
<ChatInfoPanel
|
|
266
|
+
sessionId={
|
|
267
|
+
chat.activeSource === 'opencode'
|
|
268
|
+
? (chat.activeOpencodeSessionId ?? chat.sessionId)
|
|
269
|
+
: chat.sessionId
|
|
270
|
+
}
|
|
271
|
+
messages={chat.activeSource === 'opencode' ? chat.opencodeMessages : chat.bizarMessages}
|
|
272
|
+
pinned={chat.pinned}
|
|
273
|
+
agent={agent}
|
|
274
|
+
model={model}
|
|
275
|
+
agents={snapshot.agents || []}
|
|
276
|
+
mcps={snapshot.mcps || []}
|
|
277
|
+
allCommands={allCommands}
|
|
278
|
+
/>
|
|
187
279
|
</div>
|
|
188
280
|
</div>
|
|
189
281
|
);
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
RefreshCw,
|
|
31
31
|
Send,
|
|
32
32
|
FileText,
|
|
33
|
+
Inbox,
|
|
33
34
|
} from 'lucide-react';
|
|
34
35
|
import { Button } from '../components/Button';
|
|
35
36
|
import { Card, CardTitle } from '../components/Card';
|
|
@@ -45,6 +46,7 @@ import { api, enhancePrompt } from '../lib/api';
|
|
|
45
46
|
import { cn, formatRelative, priorityColors, autoTitleFromContent } from '../lib/utils';
|
|
46
47
|
import type { Agent, Settings, Snapshot, Task } from '../lib/types';
|
|
47
48
|
import { openArtifactViewer } from '../components/ArtifactViewer';
|
|
49
|
+
import { BacklogPanel } from '../components/tasks/BacklogPanel';
|
|
48
50
|
|
|
49
51
|
type Props = {
|
|
50
52
|
snapshot: Snapshot;
|
|
@@ -319,6 +321,10 @@ export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
|
|
|
319
321
|
openArtifactViewer(modal, artifactId);
|
|
320
322
|
};
|
|
321
323
|
|
|
324
|
+
// v3.22 — Backlog panel visibility.
|
|
325
|
+
const [showBacklog, setShowBacklog] = useState(false);
|
|
326
|
+
const backlogCount = (snapshot.tasks || []).filter((t) => t.status === 'backlog').length;
|
|
327
|
+
|
|
322
328
|
return (
|
|
323
329
|
<div className="view view-tasks">
|
|
324
330
|
<header className="view-header">
|
|
@@ -447,9 +453,25 @@ export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
|
|
|
447
453
|
>
|
|
448
454
|
<Plus size={14} /> Add
|
|
449
455
|
</Button>
|
|
456
|
+
{backlogCount > 0 && (
|
|
457
|
+
<Button
|
|
458
|
+
variant={showBacklog ? 'accent' : 'ghost'}
|
|
459
|
+
size="sm"
|
|
460
|
+
onClick={() => setShowBacklog((v) => !v)}
|
|
461
|
+
title={showBacklog ? 'Hide backlog' : 'Show backlog'}
|
|
462
|
+
>
|
|
463
|
+
<Inbox size={14} />
|
|
464
|
+
Backlog
|
|
465
|
+
<span className="badge">{backlogCount}</span>
|
|
466
|
+
</Button>
|
|
467
|
+
)}
|
|
450
468
|
</div>
|
|
451
469
|
</div>
|
|
452
470
|
|
|
471
|
+
{showBacklog && backlogCount > 0 && (
|
|
472
|
+
<BacklogPanel agents={snapshot.agents || []} onRefresh={refreshSnapshot} />
|
|
473
|
+
)}
|
|
474
|
+
|
|
453
475
|
{selected.size > 0 && (
|
|
454
476
|
<div className="task-bulk-bar">
|
|
455
477
|
<span className="text-sm">
|
|
@@ -900,7 +922,7 @@ function formatDuration(seconds: number): string {
|
|
|
900
922
|
return `${m}m`;
|
|
901
923
|
}
|
|
902
924
|
|
|
903
|
-
function openTaskModal(
|
|
925
|
+
export function openTaskModal(
|
|
904
926
|
modal: ReturnType<typeof useModal>,
|
|
905
927
|
toast: ReturnType<typeof useToast>,
|
|
906
928
|
task: Task | null,
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/opencode-sessions-detail.test.mjs — v4.2.4
|
|
3
|
+
*
|
|
4
|
+
* Tests the opencode session-detail router (GET messages, POST send,
|
|
5
|
+
* GET stream SSE proxy). Strategy: stand up a fake opencode serve child
|
|
6
|
+
* on a random port, point readServeInfo() at it by writing a tmp
|
|
7
|
+
* serve.json to the first candidate path (~/.cache/bizar/serve.json).
|
|
8
|
+
* For the "plugin offline" case, move our tmp serve.json aside so
|
|
9
|
+
* readServeInfo() finds nothing.
|
|
10
|
+
*
|
|
11
|
+
* We restore the original serve.json (if any) in `after()`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import {
|
|
17
|
+
mkdtempSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
existsSync,
|
|
21
|
+
renameSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
} from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { tmpdir, homedir } from 'node:os';
|
|
27
|
+
import { createServer } from 'node:http';
|
|
28
|
+
import express from 'express';
|
|
29
|
+
|
|
30
|
+
import { createOpencodeSessionDetailRouter } from '../src/server/routes/opencode-session-detail.mjs';
|
|
31
|
+
|
|
32
|
+
// ── fake upstream (opencode serve child) ──────────────────────────────────
|
|
33
|
+
|
|
34
|
+
let upstreamServer, upstreamPort;
|
|
35
|
+
const upstream = { promptHits: [], lastPromptBody: null, streamChunks: [] };
|
|
36
|
+
|
|
37
|
+
function startUpstream() {
|
|
38
|
+
return new Promise((resolve) => {
|
|
39
|
+
upstreamServer = createServer((req, res) => {
|
|
40
|
+
const url = new URL(req.url, 'http://127.0.0.1');
|
|
41
|
+
if (url.pathname === '/event') {
|
|
42
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
43
|
+
for (const c of upstream.streamChunks) res.write(c);
|
|
44
|
+
setTimeout(() => res.end(), 50);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const promptM = url.pathname.match(/^\/api\/session\/([^/]+)\/prompt$/);
|
|
48
|
+
if (promptM && req.method === 'POST') {
|
|
49
|
+
let body = '';
|
|
50
|
+
req.on('data', (c) => { body += c; });
|
|
51
|
+
req.on('end', () => {
|
|
52
|
+
upstream.promptHits.push({ sessionId: promptM[1], body });
|
|
53
|
+
try { upstream.lastPromptBody = JSON.parse(body); } catch { /* ignore */ }
|
|
54
|
+
if (promptM[1] === 'sess-fail') {
|
|
55
|
+
res.writeHead(404, { 'Content-Type': 'application/json' }); res.end('not found'); return;
|
|
56
|
+
}
|
|
57
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{}');
|
|
58
|
+
});
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (url.pathname === '/api/session' && req.method === 'GET') {
|
|
62
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
63
|
+
res.end(JSON.stringify({ data: [{ id: 'sess-1', title: 'Test', time: { created: 1, updated: 2, archived: null }, location: { directory: '/tmp/session-worktree' } }] }));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const msgM = url.pathname.match(/^\/api\/session\/([^/]+)\/message$/);
|
|
67
|
+
if (msgM && req.method === 'GET') {
|
|
68
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
69
|
+
res.end(JSON.stringify({ data: [
|
|
70
|
+
{ info: { id: 'm1', role: 'user', time: { created: 1000 } }, parts: [{ type: 'text', text: 'hello' }] },
|
|
71
|
+
{ info: { id: 'm2', role: 'assistant', time: { created: 2000 } }, parts: [{ type: 'text', text: 'hi' }] },
|
|
72
|
+
] }));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
res.writeHead(404); res.end('not found');
|
|
76
|
+
});
|
|
77
|
+
upstreamServer.listen(0, '127.0.0.1', () => {
|
|
78
|
+
upstreamPort = upstreamServer.address().port;
|
|
79
|
+
resolve();
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function startDashboard() {
|
|
85
|
+
const app = express();
|
|
86
|
+
app.use(express.json({ limit: '2mb' }));
|
|
87
|
+
app.use('/api', createOpencodeSessionDetailRouter());
|
|
88
|
+
dashboardServer = createServer(app);
|
|
89
|
+
await new Promise((r) => dashboardServer.listen(0, '127.0.0.1', r));
|
|
90
|
+
dashboardBaseUrl = `http://127.0.0.1:${dashboardServer.address().port}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let dashboardServer, dashboardBaseUrl, tmpDir;
|
|
94
|
+
const SERVE_JSON_PATH = join(homedir(), '.cache', 'bizar', 'serve.json');
|
|
95
|
+
let originalServeJson;
|
|
96
|
+
|
|
97
|
+
before(async () => {
|
|
98
|
+
await startUpstream();
|
|
99
|
+
await startDashboard();
|
|
100
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'opencode-sessions-detail-'));
|
|
101
|
+
// Snapshot any existing serve.json so we can restore it.
|
|
102
|
+
if (existsSync(SERVE_JSON_PATH)) {
|
|
103
|
+
originalServeJson = readFileSync(SERVE_JSON_PATH, 'utf8');
|
|
104
|
+
}
|
|
105
|
+
mkdirSync(join(homedir(), '.cache', 'bizar'), { recursive: true });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
after(async () => {
|
|
109
|
+
// Restore the real serve.json (or remove ours) before exit so we
|
|
110
|
+
// never leave a fake file behind in the user's home.
|
|
111
|
+
if (originalServeJson !== undefined) {
|
|
112
|
+
writeFileSync(SERVE_JSON_PATH, originalServeJson, 'utf8');
|
|
113
|
+
} else if (existsSync(SERVE_JSON_PATH)) {
|
|
114
|
+
rmSync(SERVE_JSON_PATH);
|
|
115
|
+
}
|
|
116
|
+
if (dashboardServer) await new Promise((r) => dashboardServer.close(r));
|
|
117
|
+
if (upstreamServer) await new Promise((r) => upstreamServer.close(r));
|
|
118
|
+
if (tmpDir) rmSync(tmpDir, { recursive: true, force: true });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
upstream.promptHits = [];
|
|
123
|
+
upstream.lastPromptBody = null;
|
|
124
|
+
upstream.streamChunks = [];
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
function writeServeJson(worktree) {
|
|
128
|
+
writeFileSync(SERVE_JSON_PATH, JSON.stringify({ port: upstreamPort, password: 'test-pw', worktree, pid: 99999, startedAt: Date.now() }), 'utf8');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Move serve.json aside so readServeInfo() returns null. */
|
|
132
|
+
function moveServeJsonAside() {
|
|
133
|
+
if (!existsSync(SERVE_JSON_PATH)) return;
|
|
134
|
+
const tmp = join(tmpDir, `serve-aside-${Date.now()}.json`);
|
|
135
|
+
renameSync(SERVE_JSON_PATH, tmp);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseSseStream(text) {
|
|
139
|
+
const out = [];
|
|
140
|
+
let event = null, dataLines = [];
|
|
141
|
+
for (const line of text.split(/\r?\n/)) {
|
|
142
|
+
if (line === '') {
|
|
143
|
+
if (event !== null || dataLines.length > 0) out.push({ event, data: dataLines.join('\n') });
|
|
144
|
+
event = null; dataLines = [];
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (line.startsWith(':')) continue;
|
|
148
|
+
const colon = line.indexOf(':');
|
|
149
|
+
if (colon < 0) continue;
|
|
150
|
+
const field = line.slice(0, colon);
|
|
151
|
+
let value = line.slice(colon + 1);
|
|
152
|
+
if (value.startsWith(' ')) value = value.slice(1);
|
|
153
|
+
if (field === 'event') event = value;
|
|
154
|
+
else if (field === 'data') dataLines.push(value);
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── tests ─────────────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
test('GET messages returns 503 plugin_offline when no serve.json', async () => {
|
|
162
|
+
moveServeJsonAside();
|
|
163
|
+
try {
|
|
164
|
+
const res = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/messages`);
|
|
165
|
+
assert.equal(res.status, 503);
|
|
166
|
+
assert.equal((await res.json()).error, 'plugin_offline');
|
|
167
|
+
} finally {
|
|
168
|
+
// Restore — the next test wants serve.json present again.
|
|
169
|
+
if (originalServeJson !== undefined) writeServeJson(JSON.parse(originalServeJson).worktree);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('GET messages returns 200 with normalized message list', async () => {
|
|
174
|
+
writeServeJson('/tmp/session-worktree');
|
|
175
|
+
const res = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/messages`);
|
|
176
|
+
assert.equal(res.status, 200);
|
|
177
|
+
const body = await res.json();
|
|
178
|
+
assert.equal(body.messages.length, 2);
|
|
179
|
+
assert.equal(body.messages[0].id, 'm1');
|
|
180
|
+
assert.equal(body.messages[0].role, 'user');
|
|
181
|
+
assert.equal(body.messages[0].content, 'hello');
|
|
182
|
+
assert.equal(body.messages[0].ts, 1000);
|
|
183
|
+
assert.equal(body.messages[1].role, 'assistant');
|
|
184
|
+
assert.equal(body.messages[1].content, 'hi');
|
|
185
|
+
assert.equal(body.messages[1].ts, 2000);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('POST send returns 400 when body is empty', async () => {
|
|
189
|
+
writeServeJson('/tmp/session-worktree');
|
|
190
|
+
const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/send`, {
|
|
191
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
|
|
192
|
+
});
|
|
193
|
+
assert.equal(r.status, 400);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('POST send returns 400 when agent is missing', async () => {
|
|
197
|
+
writeServeJson('/tmp/session-worktree');
|
|
198
|
+
const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/send`, {
|
|
199
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'hi' }),
|
|
200
|
+
});
|
|
201
|
+
assert.equal(r.status, 400);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('POST send returns 400 when message is empty string', async () => {
|
|
205
|
+
writeServeJson('/tmp/session-worktree');
|
|
206
|
+
const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/send`, {
|
|
207
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: '', agent: 'odin' }),
|
|
208
|
+
});
|
|
209
|
+
assert.equal(r.status, 400);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('POST send returns ok=true with synthesized messageId', async () => {
|
|
213
|
+
writeServeJson('/tmp/session-worktree');
|
|
214
|
+
const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-1/send`, {
|
|
215
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'hello there', agent: 'odin' }),
|
|
216
|
+
});
|
|
217
|
+
assert.equal(r.status, 200);
|
|
218
|
+
const body = await r.json();
|
|
219
|
+
assert.equal(body.ok, true);
|
|
220
|
+
assert.ok(body.messageId.startsWith('msg_'));
|
|
221
|
+
assert.equal(upstream.promptHits.length, 1);
|
|
222
|
+
assert.equal(upstream.lastPromptBody.prompt.text, 'hello there');
|
|
223
|
+
assert.equal(upstream.lastPromptBody.agent, 'odin');
|
|
224
|
+
assert.equal(upstream.lastPromptBody.id, body.messageId);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('POST send returns 502 opencode_error when upstream 404s', async () => {
|
|
228
|
+
writeServeJson('/tmp/session-worktree');
|
|
229
|
+
const r = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/sess-fail/send`, {
|
|
230
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: 'hi', agent: 'odin' }),
|
|
231
|
+
});
|
|
232
|
+
assert.equal(r.status, 502);
|
|
233
|
+
const body = await r.json();
|
|
234
|
+
assert.equal(body.error, 'opencode_error');
|
|
235
|
+
assert.ok(body.message.length > 0);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('SSE stream forwards only events for the requested session', async () => {
|
|
239
|
+
writeServeJson('/tmp/session-worktree');
|
|
240
|
+
upstream.streamChunks = [
|
|
241
|
+
'event: message.updated\ndata: {"type":"message.updated","properties":{"sessionID":"our-id","messageID":"m1"}}\n\n',
|
|
242
|
+
'event: message.updated\ndata: {"type":"message.updated","properties":{"sessionID":"OTHER-id","messageID":"m9"}}\n\n',
|
|
243
|
+
'event: sync\ndata: {"type":"sync","syncEvent":{"type":"session.idle.1","data":{"sessionID":"our-id"}}}\n\n',
|
|
244
|
+
];
|
|
245
|
+
const res = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`);
|
|
246
|
+
assert.equal(res.status, 200);
|
|
247
|
+
assert.match(res.headers.get('content-type'), /text\/event-stream/);
|
|
248
|
+
assert.match(res.headers.get('cache-control'), /no-cache/);
|
|
249
|
+
assert.equal(res.headers.get('x-accel-buffering'), 'no');
|
|
250
|
+
const events = parseSseStream(await res.text());
|
|
251
|
+
assert.equal(events.length, 2);
|
|
252
|
+
assert.equal(events[0].event, 'message.updated');
|
|
253
|
+
const d0 = JSON.parse(events[0].data);
|
|
254
|
+
assert.equal(d0.sessionID, 'our-id');
|
|
255
|
+
assert.equal(d0.messageID, 'm1');
|
|
256
|
+
assert.equal(events[1].event, 'session.idle');
|
|
257
|
+
assert.equal(JSON.parse(events[1].data).sessionID, 'our-id');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('SSE stream unwraps sync envelopes and strips version suffix', async () => {
|
|
261
|
+
writeServeJson('/tmp/session-worktree');
|
|
262
|
+
upstream.streamChunks = [
|
|
263
|
+
'event: sync\ndata: {"type":"sync","syncEvent":{"type":"message.updated.1","data":{"sessionID":"our-id","messageID":"m3"}}}\n\n',
|
|
264
|
+
];
|
|
265
|
+
const res = await fetch(`${dashboardBaseUrl}/api/opencode-sessions/our-id/stream`);
|
|
266
|
+
assert.equal(res.status, 200);
|
|
267
|
+
const events = parseSseStream(await res.text());
|
|
268
|
+
assert.equal(events.length, 1);
|
|
269
|
+
assert.equal(events[0].event, 'message.updated');
|
|
270
|
+
const d = JSON.parse(events[0].data);
|
|
271
|
+
assert.equal(d.sessionID, 'our-id');
|
|
272
|
+
assert.equal(d.messageID, 'm3');
|
|
273
|
+
});
|