oh-my-opencode-dashboard 0.0.4 → 0.0.5
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/dist/assets/index--GqzhA4-.css +1 -0
- package/dist/assets/index-CiC6k4Yg.js +40 -0
- package/dist/index.html +3 -3
- package/package.json +1 -1
- package/src/App.tsx +291 -16
- package/src/background-task-toolcalls-policy.test.ts +191 -0
- package/src/ingest/background-tasks.test.ts +11 -2
- package/src/ingest/tool-calls.test.ts +161 -0
- package/src/ingest/tool-calls.ts +157 -0
- package/src/server/api.test.ts +162 -53
- package/src/server/api.ts +39 -2
- package/src/server/dashboard.ts +2 -0
- package/src/server/dev.ts +4 -2
- package/src/server/start.ts +4 -2
- package/src/styles.css +131 -0
- package/dist/assets/index-CZM2MUUs.js +0 -40
- package/dist/assets/index-RAZRO3YN.css +0 -1
package/src/App.tsx
CHANGED
|
@@ -14,12 +14,30 @@ type BackgroundTask = {
|
|
|
14
14
|
subline?: string;
|
|
15
15
|
agent: string;
|
|
16
16
|
lastModel: string;
|
|
17
|
+
sessionId?: string | null;
|
|
17
18
|
status: "queued" | "running" | "done" | "error" | "cancelled" | string;
|
|
18
19
|
toolCalls: number;
|
|
19
20
|
lastTool: string;
|
|
20
21
|
timeline: string;
|
|
21
22
|
};
|
|
22
23
|
|
|
24
|
+
type ToolCallSummary = {
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
messageId: string;
|
|
27
|
+
callId: string;
|
|
28
|
+
tool: string;
|
|
29
|
+
status: string;
|
|
30
|
+
createdAtMs: number | null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type ToolCallsResponse = {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
sessionId: string;
|
|
36
|
+
toolCalls: ToolCallSummary[];
|
|
37
|
+
caps?: { maxMessages: number; maxToolCalls: number };
|
|
38
|
+
truncated?: boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
23
41
|
type TimeSeriesTone = "muted" | "teal" | "red" | "green";
|
|
24
42
|
|
|
25
43
|
type TimeSeriesSeriesId =
|
|
@@ -198,6 +216,7 @@ const FALLBACK_DATA: DashboardPayload = {
|
|
|
198
216
|
subline: "task-1",
|
|
199
217
|
agent: "explore",
|
|
200
218
|
lastModel: "opencode/gpt-5-nano",
|
|
219
|
+
sessionId: null,
|
|
201
220
|
status: "running",
|
|
202
221
|
toolCalls: 3,
|
|
203
222
|
lastTool: "grep",
|
|
@@ -282,6 +301,62 @@ function toNonEmptyString(value: unknown): string | null {
|
|
|
282
301
|
return trimmed.length > 0 ? trimmed : null;
|
|
283
302
|
}
|
|
284
303
|
|
|
304
|
+
function toToolCallSummary(value: unknown): ToolCallSummary | null {
|
|
305
|
+
if (!value || typeof value !== "object") return null;
|
|
306
|
+
const rec = value as Record<string, unknown>;
|
|
307
|
+
|
|
308
|
+
const messageId = toNonEmptyString(rec.messageId ?? rec.message_id);
|
|
309
|
+
const callId = toNonEmptyString(rec.callId ?? rec.call_id);
|
|
310
|
+
const tool = toNonEmptyString(rec.tool);
|
|
311
|
+
const status = toNonEmptyString(rec.status) ?? "unknown";
|
|
312
|
+
const createdAtRaw = toFiniteNumber(rec.createdAtMs ?? rec.created_at_ms ?? rec.createdAt ?? rec.created_at);
|
|
313
|
+
|
|
314
|
+
if (!messageId || !callId || !tool) return null;
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
sessionId: toNonEmptyString(rec.sessionId ?? rec.session_id) ?? undefined,
|
|
318
|
+
messageId,
|
|
319
|
+
callId,
|
|
320
|
+
tool,
|
|
321
|
+
status,
|
|
322
|
+
createdAtMs: typeof createdAtRaw === "number" ? Math.floor(createdAtRaw) : null,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function toToolCallsResponse(value: unknown): ToolCallsResponse | null {
|
|
327
|
+
if (!value || typeof value !== "object") return null;
|
|
328
|
+
const rec = value as Record<string, unknown>;
|
|
329
|
+
|
|
330
|
+
const ok = typeof rec.ok === "boolean" ? rec.ok : false;
|
|
331
|
+
const sessionId = toNonEmptyString(rec.sessionId ?? rec.session_id);
|
|
332
|
+
const toolCallsRaw = rec.toolCalls ?? rec.tool_calls;
|
|
333
|
+
|
|
334
|
+
if (!sessionId || !Array.isArray(toolCallsRaw)) return null;
|
|
335
|
+
|
|
336
|
+
const toolCalls: ToolCallSummary[] = toolCallsRaw
|
|
337
|
+
.map(toToolCallSummary)
|
|
338
|
+
.filter((t): t is ToolCallSummary => t !== null);
|
|
339
|
+
|
|
340
|
+
const capsRaw = rec.caps;
|
|
341
|
+
const capsObj = capsRaw && typeof capsRaw === "object" ? (capsRaw as Record<string, unknown>) : null;
|
|
342
|
+
const maxMessagesRaw = capsObj ? toFiniteNumber(capsObj.maxMessages ?? capsObj.max_messages) : null;
|
|
343
|
+
const maxToolCallsRaw = capsObj ? toFiniteNumber(capsObj.maxToolCalls ?? capsObj.max_tool_calls) : null;
|
|
344
|
+
const caps =
|
|
345
|
+
typeof maxMessagesRaw === "number" && typeof maxToolCallsRaw === "number"
|
|
346
|
+
? { maxMessages: Math.max(0, Math.floor(maxMessagesRaw)), maxToolCalls: Math.max(0, Math.floor(maxToolCallsRaw)) }
|
|
347
|
+
: undefined;
|
|
348
|
+
|
|
349
|
+
const truncated = typeof rec.truncated === "boolean" ? rec.truncated : undefined;
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
ok,
|
|
353
|
+
sessionId,
|
|
354
|
+
toolCalls,
|
|
355
|
+
caps,
|
|
356
|
+
truncated,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
285
360
|
export function formatBackgroundTaskTimelineCell(status: unknown, timeline: unknown): string {
|
|
286
361
|
const s = typeof status === "string" ? status.trim().toLowerCase() : "";
|
|
287
362
|
if (s === "unknown") return "";
|
|
@@ -290,6 +365,50 @@ export function formatBackgroundTaskTimelineCell(status: unknown, timeline: unkn
|
|
|
290
365
|
return toNonEmptyString(timeline) ?? "-";
|
|
291
366
|
}
|
|
292
367
|
|
|
368
|
+
export function computeToolCallsFetchPlan(params: {
|
|
369
|
+
sessionId: string | null;
|
|
370
|
+
status: string;
|
|
371
|
+
cachedState: "idle" | "loading" | "ok" | "error" | null;
|
|
372
|
+
cachedDataOk: boolean;
|
|
373
|
+
isExpanded: boolean;
|
|
374
|
+
}): { shouldFetch: boolean; force: boolean } {
|
|
375
|
+
const { sessionId, status, cachedState, cachedDataOk, isExpanded } = params;
|
|
376
|
+
|
|
377
|
+
if (!sessionId) {
|
|
378
|
+
return { shouldFetch: false, force: false };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (!isExpanded) {
|
|
382
|
+
return { shouldFetch: false, force: false };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const isRunning = String(status ?? "").toLowerCase().trim() === "running";
|
|
386
|
+
|
|
387
|
+
if (isRunning) {
|
|
388
|
+
return { shouldFetch: true, force: true };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (cachedDataOk) {
|
|
392
|
+
return { shouldFetch: false, force: false };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (cachedState === "loading") {
|
|
396
|
+
return { shouldFetch: false, force: false };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return { shouldFetch: true, force: false };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function toggleIdInSet(id: string, currentSet: Set<string>): Set<string> {
|
|
403
|
+
const next = new Set(currentSet);
|
|
404
|
+
if (next.has(id)) {
|
|
405
|
+
next.delete(id);
|
|
406
|
+
} else {
|
|
407
|
+
next.add(id);
|
|
408
|
+
}
|
|
409
|
+
return next;
|
|
410
|
+
}
|
|
411
|
+
|
|
293
412
|
function toDashboardPayload(json: unknown): DashboardPayload {
|
|
294
413
|
if (!json || typeof json !== "object") {
|
|
295
414
|
return { ...FALLBACK_DATA, raw: json };
|
|
@@ -331,6 +450,7 @@ function toDashboardPayload(json: unknown): DashboardPayload {
|
|
|
331
450
|
: undefined,
|
|
332
451
|
agent: String(rec.agent ?? rec.worker ?? "unknown"),
|
|
333
452
|
lastModel: toNonEmptyString(rec.lastModel ?? rec.last_model) ?? "-",
|
|
453
|
+
sessionId: toNonEmptyString(rec.sessionId ?? rec.session_id),
|
|
334
454
|
status: String(rec.status ?? "queued"),
|
|
335
455
|
toolCalls: Number(rec.toolCalls ?? rec.tool_calls ?? 0) || 0,
|
|
336
456
|
lastTool: String(rec.lastTool ?? rec.last_tool ?? "-") || "-",
|
|
@@ -379,6 +499,12 @@ export default function App() {
|
|
|
379
499
|
const [planOpen, setPlanOpen] = React.useState(false);
|
|
380
500
|
const [errorHint, setErrorHint] = React.useState<string | null>(null);
|
|
381
501
|
|
|
502
|
+
const [expandedBgTaskIds, setExpandedBgTaskIds] = React.useState<Set<string>>(() => new Set());
|
|
503
|
+
const [toolCallsBySession, setToolCallsBySession] = React.useState<
|
|
504
|
+
Map<string, { state: "idle" | "loading" | "ok" | "error"; data: ToolCallsResponse | null; lastFetchedAtMs: number | null }>
|
|
505
|
+
>(() => new Map());
|
|
506
|
+
const toolCallsSeqRef = React.useRef<Map<string, number>>(new Map());
|
|
507
|
+
|
|
382
508
|
const timerRef = React.useRef<number | null>(null);
|
|
383
509
|
const hadSuccessRef = React.useRef(false);
|
|
384
510
|
const soundEnabledRef = React.useRef(false);
|
|
@@ -580,6 +706,75 @@ export default function App() {
|
|
|
580
706
|
return map;
|
|
581
707
|
}, [data.timeSeries.series]);
|
|
582
708
|
|
|
709
|
+
async function fetchToolCalls(sessionId: string, opts: { force: boolean }) {
|
|
710
|
+
const existing = toolCallsBySession.get(sessionId);
|
|
711
|
+
if (!opts.force && existing?.data?.ok) return;
|
|
712
|
+
|
|
713
|
+
const seq = (toolCallsSeqRef.current.get(sessionId) ?? 0) + 1;
|
|
714
|
+
toolCallsSeqRef.current.set(sessionId, seq);
|
|
715
|
+
|
|
716
|
+
setToolCallsBySession((prev) => {
|
|
717
|
+
const next = new Map(prev);
|
|
718
|
+
const prior = next.get(sessionId);
|
|
719
|
+
next.set(sessionId, {
|
|
720
|
+
state: "loading",
|
|
721
|
+
data: prior?.data ?? null,
|
|
722
|
+
lastFetchedAtMs: prior?.lastFetchedAtMs ?? null,
|
|
723
|
+
});
|
|
724
|
+
return next;
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
try {
|
|
728
|
+
const raw = await safeFetchJson(`/api/tool-calls/${encodeURIComponent(sessionId)}`);
|
|
729
|
+
const parsed = toToolCallsResponse(raw);
|
|
730
|
+
if (!parsed?.ok) throw new Error("tool calls not ok");
|
|
731
|
+
if (toolCallsSeqRef.current.get(sessionId) !== seq) return;
|
|
732
|
+
setToolCallsBySession((prev) => {
|
|
733
|
+
const next = new Map(prev);
|
|
734
|
+
next.set(sessionId, { state: "ok", data: parsed, lastFetchedAtMs: Date.now() });
|
|
735
|
+
return next;
|
|
736
|
+
});
|
|
737
|
+
} catch {
|
|
738
|
+
if (toolCallsSeqRef.current.get(sessionId) !== seq) return;
|
|
739
|
+
setToolCallsBySession((prev) => {
|
|
740
|
+
const next = new Map(prev);
|
|
741
|
+
const prior = next.get(sessionId);
|
|
742
|
+
next.set(sessionId, {
|
|
743
|
+
state: "error",
|
|
744
|
+
data: prior?.data ?? null,
|
|
745
|
+
lastFetchedAtMs: prior?.lastFetchedAtMs ?? null,
|
|
746
|
+
});
|
|
747
|
+
return next;
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function toggleBackgroundTaskExpanded(t: BackgroundTask) {
|
|
753
|
+
const nextExpanded = !expandedBgTaskIds.has(t.id);
|
|
754
|
+
setExpandedBgTaskIds((prev) => {
|
|
755
|
+
const next = new Set(prev);
|
|
756
|
+
if (nextExpanded) next.add(t.id);
|
|
757
|
+
else next.delete(t.id);
|
|
758
|
+
return next;
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
if (!nextExpanded) return;
|
|
762
|
+
|
|
763
|
+
const sessionId = toNonEmptyString(t.sessionId);
|
|
764
|
+
if (!sessionId) return;
|
|
765
|
+
|
|
766
|
+
const isRunning = String(t.status ?? "").toLowerCase().trim() === "running";
|
|
767
|
+
const cached = toolCallsBySession.get(sessionId);
|
|
768
|
+
if (isRunning) {
|
|
769
|
+
void fetchToolCalls(sessionId, { force: true });
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (cached?.data?.ok) return;
|
|
774
|
+
if (cached?.state === "loading") return;
|
|
775
|
+
void fetchToolCalls(sessionId, { force: false });
|
|
776
|
+
}
|
|
777
|
+
|
|
583
778
|
const buckets = Math.max(1, data.timeSeries.buckets);
|
|
584
779
|
const bucketMs = Math.max(1, data.timeSeries.bucketMs);
|
|
585
780
|
const viewBox = `0 0 ${buckets} 28`;
|
|
@@ -931,22 +1126,102 @@ export default function App() {
|
|
|
931
1126
|
</tr>
|
|
932
1127
|
</thead>
|
|
933
1128
|
<tbody>
|
|
934
|
-
{data.backgroundTasks.map((t) =>
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
<
|
|
948
|
-
|
|
949
|
-
|
|
1129
|
+
{data.backgroundTasks.map((t) => {
|
|
1130
|
+
const expanded = expandedBgTaskIds.has(t.id);
|
|
1131
|
+
const sessionId = toNonEmptyString(t.sessionId);
|
|
1132
|
+
const detailId = `bg-toolcalls-${t.id}`;
|
|
1133
|
+
const entry = sessionId ? toolCallsBySession.get(sessionId) : null;
|
|
1134
|
+
const toolCalls = entry?.data?.ok ? entry.data.toolCalls : [];
|
|
1135
|
+
const showCapped = Boolean(entry?.data?.truncated);
|
|
1136
|
+
const caps = entry?.data?.caps;
|
|
1137
|
+
const showLoading = entry?.state === "loading";
|
|
1138
|
+
const showError = entry?.state === "error" && !entry?.data?.ok;
|
|
1139
|
+
const empty = sessionId ? toolCalls.length === 0 && !showLoading && !showError : true;
|
|
1140
|
+
|
|
1141
|
+
return (
|
|
1142
|
+
<React.Fragment key={t.id}>
|
|
1143
|
+
<tr>
|
|
1144
|
+
<td>
|
|
1145
|
+
<div className="bgTaskRowTitleWrap">
|
|
1146
|
+
<button
|
|
1147
|
+
type="button"
|
|
1148
|
+
className="bgTaskToggle"
|
|
1149
|
+
onClick={() => toggleBackgroundTaskExpanded(t)}
|
|
1150
|
+
aria-expanded={expanded}
|
|
1151
|
+
aria-controls={detailId}
|
|
1152
|
+
title={expanded ? "Collapse" : "Expand"}
|
|
1153
|
+
aria-label={expanded ? "Collapse tool calls" : "Expand tool calls"}
|
|
1154
|
+
/>
|
|
1155
|
+
<div className="bgTaskRowTitleText">
|
|
1156
|
+
<div className="taskTitle">{t.description}</div>
|
|
1157
|
+
{t.subline ? <div className="taskSub mono">{t.subline}</div> : null}
|
|
1158
|
+
</div>
|
|
1159
|
+
</div>
|
|
1160
|
+
</td>
|
|
1161
|
+
<td className="mono">{t.agent}</td>
|
|
1162
|
+
<td className="mono">{t.lastModel}</td>
|
|
1163
|
+
<td>
|
|
1164
|
+
<span className={`pill pill-${statusTone(t.status)}`}>{t.status}</span>
|
|
1165
|
+
</td>
|
|
1166
|
+
<td className="mono">{t.toolCalls}</td>
|
|
1167
|
+
<td className="mono">{t.lastTool}</td>
|
|
1168
|
+
<td className="mono muted">{formatBackgroundTaskTimelineCell(t.status, t.timeline)}</td>
|
|
1169
|
+
</tr>
|
|
1170
|
+
|
|
1171
|
+
{expanded ? (
|
|
1172
|
+
<tr>
|
|
1173
|
+
<td colSpan={7} className="bgTaskDetailCell">
|
|
1174
|
+
<section id={detailId} aria-label="Tool calls" className="bgTaskDetail">
|
|
1175
|
+
<div className="mono muted bgTaskDetailHeader">
|
|
1176
|
+
Tool calls (metadata only){showLoading && toolCalls.length > 0 ? " - refreshing" : ""}
|
|
1177
|
+
{showCapped
|
|
1178
|
+
? ` - capped${caps ? ` (max ${caps.maxMessages} messages / ${caps.maxToolCalls} tool calls)` : ""}`
|
|
1179
|
+
: ""}
|
|
1180
|
+
</div>
|
|
1181
|
+
|
|
1182
|
+
{!sessionId ? (
|
|
1183
|
+
<div className="muted bgTaskDetailEmpty">
|
|
1184
|
+
No session id available for this task.
|
|
1185
|
+
</div>
|
|
1186
|
+
) : showError ? (
|
|
1187
|
+
<div className="muted bgTaskDetailEmpty">
|
|
1188
|
+
Tool calls unavailable.
|
|
1189
|
+
</div>
|
|
1190
|
+
) : showLoading && toolCalls.length === 0 ? (
|
|
1191
|
+
<div className="muted bgTaskDetailEmpty">
|
|
1192
|
+
Loading tool calls...
|
|
1193
|
+
</div>
|
|
1194
|
+
) : empty ? (
|
|
1195
|
+
<div className="muted bgTaskDetailEmpty">
|
|
1196
|
+
No tool calls recorded.
|
|
1197
|
+
</div>
|
|
1198
|
+
) : (
|
|
1199
|
+
<div className="bgTaskToolCallsGrid">
|
|
1200
|
+
{toolCalls.map((c) => (
|
|
1201
|
+
<div key={c.callId} className="bgTaskToolCall">
|
|
1202
|
+
<div className="bgTaskToolCallRow">
|
|
1203
|
+
<div className="mono bgTaskToolCallTool" title={c.tool}>
|
|
1204
|
+
{c.tool}
|
|
1205
|
+
</div>
|
|
1206
|
+
<div className="mono muted bgTaskToolCallStatus" title={c.status}>
|
|
1207
|
+
{c.status}
|
|
1208
|
+
</div>
|
|
1209
|
+
</div>
|
|
1210
|
+
<div className="mono muted bgTaskToolCallTime">{formatTime(c.createdAtMs)}</div>
|
|
1211
|
+
<div className="mono muted bgTaskToolCallId" title={c.callId}>
|
|
1212
|
+
{c.callId}
|
|
1213
|
+
</div>
|
|
1214
|
+
</div>
|
|
1215
|
+
))}
|
|
1216
|
+
</div>
|
|
1217
|
+
)}
|
|
1218
|
+
</section>
|
|
1219
|
+
</td>
|
|
1220
|
+
</tr>
|
|
1221
|
+
) : null}
|
|
1222
|
+
</React.Fragment>
|
|
1223
|
+
);
|
|
1224
|
+
})}
|
|
950
1225
|
</tbody>
|
|
951
1226
|
</table>
|
|
952
1227
|
</div>
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { computeToolCallsFetchPlan, toggleIdInSet } from "./App"
|
|
3
|
+
|
|
4
|
+
describe('computeToolCallsFetchPlan', () => {
|
|
5
|
+
it('should not fetch when sessionId is missing', () => {
|
|
6
|
+
// #given: no sessionId
|
|
7
|
+
const params = {
|
|
8
|
+
sessionId: null,
|
|
9
|
+
status: "running",
|
|
10
|
+
cachedState: "idle" as const,
|
|
11
|
+
cachedDataOk: false,
|
|
12
|
+
isExpanded: true
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// #when
|
|
16
|
+
const result = computeToolCallsFetchPlan(params)
|
|
17
|
+
|
|
18
|
+
// #then
|
|
19
|
+
expect(result.shouldFetch).toBe(false)
|
|
20
|
+
expect(result.force).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should not fetch when not expanded', () => {
|
|
24
|
+
// #given: expanded is false
|
|
25
|
+
const params = {
|
|
26
|
+
sessionId: "session-123",
|
|
27
|
+
status: "done",
|
|
28
|
+
cachedState: "idle" as const,
|
|
29
|
+
cachedDataOk: false,
|
|
30
|
+
isExpanded: false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// #when
|
|
34
|
+
const result = computeToolCallsFetchPlan(params)
|
|
35
|
+
|
|
36
|
+
// #then
|
|
37
|
+
expect(result.shouldFetch).toBe(false)
|
|
38
|
+
expect(result.force).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should force fetch when status is "running" and expanded', () => {
|
|
42
|
+
// #given: running status
|
|
43
|
+
const params = {
|
|
44
|
+
sessionId: "session-123",
|
|
45
|
+
status: "running",
|
|
46
|
+
cachedState: "ok" as const,
|
|
47
|
+
cachedDataOk: true,
|
|
48
|
+
isExpanded: true
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// #when
|
|
52
|
+
const result = computeToolCallsFetchPlan(params)
|
|
53
|
+
|
|
54
|
+
// #then
|
|
55
|
+
expect(result.shouldFetch).toBe(true)
|
|
56
|
+
expect(result.force).toBe(true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('should not fetch when non-running status and cached data is ok', () => {
|
|
60
|
+
// #given: done status with good cache
|
|
61
|
+
const params = {
|
|
62
|
+
sessionId: "session-123",
|
|
63
|
+
status: "done",
|
|
64
|
+
cachedState: "ok" as const,
|
|
65
|
+
cachedDataOk: true,
|
|
66
|
+
isExpanded: true
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// #when
|
|
70
|
+
const result = computeToolCallsFetchPlan(params)
|
|
71
|
+
|
|
72
|
+
// #then
|
|
73
|
+
expect(result.shouldFetch).toBe(false)
|
|
74
|
+
expect(result.force).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should not fetch when already loading', () => {
|
|
78
|
+
// #given: loading state
|
|
79
|
+
const params = {
|
|
80
|
+
sessionId: "session-123",
|
|
81
|
+
status: "done",
|
|
82
|
+
cachedState: "loading" as const,
|
|
83
|
+
cachedDataOk: false,
|
|
84
|
+
isExpanded: true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// #when
|
|
88
|
+
const result = computeToolCallsFetchPlan(params)
|
|
89
|
+
|
|
90
|
+
// #then
|
|
91
|
+
expect(result.shouldFetch).toBe(false)
|
|
92
|
+
expect(result.force).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('should fetch when non-running, not cached, not loading, and expanded', () => {
|
|
96
|
+
// #given: done status with no cache
|
|
97
|
+
const params = {
|
|
98
|
+
sessionId: "session-123",
|
|
99
|
+
status: "done",
|
|
100
|
+
cachedState: "idle" as const,
|
|
101
|
+
cachedDataOk: false,
|
|
102
|
+
isExpanded: true
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// #when
|
|
106
|
+
const result = computeToolCallsFetchPlan(params)
|
|
107
|
+
|
|
108
|
+
// #then
|
|
109
|
+
expect(result.shouldFetch).toBe(true)
|
|
110
|
+
expect(result.force).toBe(false)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('should handle case-insensitive status values', () => {
|
|
114
|
+
// #given: RUNNING in uppercase
|
|
115
|
+
const params = {
|
|
116
|
+
sessionId: "session-123",
|
|
117
|
+
status: "RUNNING",
|
|
118
|
+
cachedState: "idle" as const,
|
|
119
|
+
cachedDataOk: false,
|
|
120
|
+
isExpanded: true
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// #when
|
|
124
|
+
const result = computeToolCallsFetchPlan(params)
|
|
125
|
+
|
|
126
|
+
// #then
|
|
127
|
+
expect(result.shouldFetch).toBe(true)
|
|
128
|
+
expect(result.force).toBe(true)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('should handle whitespace in status values', () => {
|
|
132
|
+
// #given: running with whitespace
|
|
133
|
+
const params = {
|
|
134
|
+
sessionId: "session-123",
|
|
135
|
+
status: " running ",
|
|
136
|
+
cachedState: "idle" as const,
|
|
137
|
+
cachedDataOk: false,
|
|
138
|
+
isExpanded: true
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// #when
|
|
142
|
+
const result = computeToolCallsFetchPlan(params)
|
|
143
|
+
|
|
144
|
+
// #then
|
|
145
|
+
expect(result.shouldFetch).toBe(true)
|
|
146
|
+
expect(result.force).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('toggleIdInSet', () => {
|
|
151
|
+
it('should add id when not present in set', () => {
|
|
152
|
+
// #given: empty set
|
|
153
|
+
const currentSet = new Set<string>()
|
|
154
|
+
|
|
155
|
+
// #when
|
|
156
|
+
const result = toggleIdInSet("task-1", currentSet)
|
|
157
|
+
|
|
158
|
+
// #then
|
|
159
|
+
expect(result.has("task-1")).toBe(true)
|
|
160
|
+
expect(result.size).toBe(1)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('should remove id when already present in set', () => {
|
|
164
|
+
// #given: set with id already present
|
|
165
|
+
const currentSet = new Set(["task-1", "task-2"])
|
|
166
|
+
|
|
167
|
+
// #when
|
|
168
|
+
const result = toggleIdInSet("task-1", currentSet)
|
|
169
|
+
|
|
170
|
+
// #then
|
|
171
|
+
expect(result.has("task-1")).toBe(false)
|
|
172
|
+
expect(result.has("task-2")).toBe(true)
|
|
173
|
+
expect(result.size).toBe(1)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('should not modify original set', () => {
|
|
177
|
+
// #given: original set
|
|
178
|
+
const originalSet = new Set(["task-1"])
|
|
179
|
+
|
|
180
|
+
// #when
|
|
181
|
+
const result = toggleIdInSet("task-2", originalSet)
|
|
182
|
+
|
|
183
|
+
// #then
|
|
184
|
+
expect(originalSet.has("task-1")).toBe(true)
|
|
185
|
+
expect(originalSet.has("task-2")).toBe(false)
|
|
186
|
+
expect(originalSet.size).toBe(1)
|
|
187
|
+
expect(result.has("task-1")).toBe(true)
|
|
188
|
+
expect(result.has("task-2")).toBe(true)
|
|
189
|
+
expect(result.size).toBe(2)
|
|
190
|
+
})
|
|
191
|
+
})
|
|
@@ -981,7 +981,16 @@ describe("deriveBackgroundTasks", () => {
|
|
|
981
981
|
"utf8"
|
|
982
982
|
)
|
|
983
983
|
|
|
984
|
-
|
|
984
|
+
// Create typed wrapper that records calls and delegates to fs.readdirSync
|
|
985
|
+
const readdirCalls: Array<[fs.PathLike]> = []
|
|
986
|
+
const readdirSync = ((path: fs.PathLike, options?: any): any => {
|
|
987
|
+
if (typeof options === 'object' && options.withFileTypes) {
|
|
988
|
+
return fs.readdirSync(path, options)
|
|
989
|
+
}
|
|
990
|
+
readdirCalls.push([path])
|
|
991
|
+
return fs.readdirSync(path, 'utf8')
|
|
992
|
+
}) as typeof fs.readdirSync
|
|
993
|
+
|
|
985
994
|
const fsLike = {
|
|
986
995
|
readFileSync: fs.readFileSync,
|
|
987
996
|
readdirSync,
|
|
@@ -993,7 +1002,7 @@ describe("deriveBackgroundTasks", () => {
|
|
|
993
1002
|
const rows = deriveBackgroundTasks({ storage, mainSessionId, fs: fsLike })
|
|
994
1003
|
|
|
995
1004
|
// #then
|
|
996
|
-
const backgroundReads =
|
|
1005
|
+
const backgroundReads = readdirCalls.filter((call) => call[0] === childMsgDir)
|
|
997
1006
|
expect(rows.length).toBe(2)
|
|
998
1007
|
expect(backgroundReads.length).toBe(1)
|
|
999
1008
|
})
|