@nanobpm/nano-workforce 0.183.2 → 0.184.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.
@@ -0,0 +1,104 @@
1
+ // Pure view-model tests for the cockpit engine-native agent-history view (issue #745/#747).
2
+ import assert from "node:assert/strict";
3
+ import { test } from "node:test";
4
+ import type {
5
+ AgentHistory as AgentHistoryReport,
6
+ AgentInstanceList as AgentInstanceListReport,
7
+ } from "../../../nano-generated/api-io.d.ts";
8
+ import { agentHistoryView, agentSessionsView } from "./agent-history-view.ts";
9
+
10
+ const list = (instances: AgentInstanceListReport["instances"]): AgentInstanceListReport => ({
11
+ count: instances.length,
12
+ instances,
13
+ });
14
+
15
+ test("agentSessionsView labels, rolls up metrics, and sorts newest-captured first", () => {
16
+ const view = agentSessionsView(
17
+ list([
18
+ {
19
+ agentInstanceKey: "old",
20
+ status: "COMPLETED",
21
+ processInstanceKey: "pi-1",
22
+ processDefinitionId: "feature",
23
+ elementId: "implement-task",
24
+ completionDate: "2024-01-01T00:00:00Z",
25
+ metrics: { inputTokens: 1500, outputTokens: 340, modelCalls: 3, toolCalls: 5 },
26
+ },
27
+ {
28
+ agentInstanceKey: "new",
29
+ status: "THINKING",
30
+ processInstanceKey: "pi-2",
31
+ lastUpdatedDate: "2024-02-01T00:00:00Z",
32
+ },
33
+ ]),
34
+ );
35
+ assert.deepEqual(
36
+ view.sessions.map((s) => s.agentInstanceKey),
37
+ ["new", "old"],
38
+ );
39
+ const old = view.sessions[1];
40
+ assert.equal(old?.label, "feature \u00b7 implement-task \u00b7 inst pi-1");
41
+ assert.equal(old?.metrics, "1.5k in \u00b7 340 out \u00b7 3 calls \u00b7 5 tools");
42
+ assert.equal(old?.capturedAt, "2024-01-01T00:00:00Z");
43
+ // No metrics reported -> no metrics string.
44
+ assert.equal(view.sessions[0]?.metrics, undefined);
45
+ });
46
+
47
+ test("agentHistoryView projects turns in transport order with text, tool calls and per-turn metrics", () => {
48
+ const report: AgentHistoryReport = {
49
+ agentInstanceKey: "ai-1",
50
+ count: 2,
51
+ instance: {
52
+ agentInstanceKey: "ai-1",
53
+ status: "COMPLETED",
54
+ processInstanceKey: "pi-1",
55
+ metrics: { inputTokens: 20, outputTokens: 8, modelCalls: 2, toolCalls: 1 },
56
+ },
57
+ records: [
58
+ {
59
+ historyItemKey: "h-0",
60
+ agentInstanceKey: "ai-1",
61
+ loopIteration: 0,
62
+ role: "USER",
63
+ commitStatus: "COMMITTED",
64
+ content: [{ contentType: "TEXT", text: "do the thing" }],
65
+ toolCalls: [],
66
+ },
67
+ {
68
+ historyItemKey: "h-1",
69
+ agentInstanceKey: "ai-1",
70
+ loopIteration: 1,
71
+ role: "ASSISTANT",
72
+ commitStatus: "COMMITTED",
73
+ content: [
74
+ { contentType: "TEXT", text: "on it" },
75
+ { contentType: "OBJECT", object: { ignored: true } },
76
+ ],
77
+ toolCalls: [{ toolCallId: "t-1", toolName: "grep", elementId: "tool", arguments: {} }],
78
+ metrics: {
79
+ inputTokens: 12,
80
+ outputTokens: 4,
81
+ reasoningTokenCount: 0,
82
+ cacheCreationTokenCount: 0,
83
+ cacheReadTokenCount: 0,
84
+ durationMs: 900,
85
+ },
86
+ },
87
+ ],
88
+ };
89
+ const view = agentHistoryView(report);
90
+ assert.equal(view.count, 2);
91
+ assert.equal(view.instance?.metrics, "20 in \u00b7 8 out \u00b7 2 calls \u00b7 1 tools");
92
+ assert.equal(view.turns[0]?.text, "do the thing");
93
+ // OBJECT blocks drop out of the rendered text; only TEXT joins.
94
+ assert.equal(view.turns[1]?.text, "on it");
95
+ assert.equal(view.turns[1]?.toolCalls[0]?.toolName, "grep");
96
+ assert.equal(view.turns[1]?.metrics, "12 in \u00b7 4 out \u00b7 900ms");
97
+ });
98
+
99
+ test("agentHistoryView read-as-absence: an empty history yields zero turns", () => {
100
+ const view = agentHistoryView({ agentInstanceKey: "ai-9", count: 0, records: [] });
101
+ assert.equal(view.count, 0);
102
+ assert.equal(view.turns.length, 0);
103
+ assert.equal(view.instance, undefined);
104
+ });
@@ -0,0 +1,186 @@
1
+ // The cockpit engine-native "agent history" view-model (ADR 0056, issue #745/#747, umbrella #746).
2
+ //
3
+ // A pure, deterministic projection of the engine AgentInstance / AgentHistory read model
4
+ // (`GET /agentic/agent-instances` + `…/{agentInstanceKey}/history`, served from
5
+ // `@nanobpm/urban`'s EngineClient `searchAgentInstances` / `searchAgentInstanceHistory`) onto the
6
+ // shapes the cockpit's HISTORICAL (settled) transcript + metrics view renders. This is the CONSUMER
7
+ // half of the durable-agent-transcript work: settled history now derives from engine truth, keyed by
8
+ // AGENT-INSTANCE / PROCESS / ELEMENT-INSTANCE keys — never the slash-bearing `job:<jobKey>` relay
9
+ // stream id (so the #744 gateway-proxy bug class is moot for historical reads). The token-granular
10
+ // relay (`./transcript-view.ts`) stays the LIVE overlay only.
11
+ //
12
+ // Like its relay sibling `./transcript-view.ts` and `./supply-view.ts`, it is framework-free and
13
+ // side-effect-free: the same report always yields the same view, so it renders identically embedded
14
+ // (App View) and standalone, and is unit-testable on Node with no browser.
15
+
16
+ import type {
17
+ AgentHistoryRecord,
18
+ AgentHistory as AgentHistoryReport,
19
+ AgentInstanceList as AgentInstanceListReport,
20
+ AgentInstance as AgentInstanceReport,
21
+ } from "../../../nano-generated/api-io.d.ts";
22
+
23
+ export type { AgentHistoryReport, AgentInstanceListReport };
24
+
25
+ /** One agent-instance row in the renderable historical-sessions list. */
26
+ export interface AgentSessionView {
27
+ /** The engine-unique agent-instance key — the identity the history read is keyed on. */
28
+ readonly agentInstanceKey: string;
29
+ /** A single stable human label for the run's process / element (falls back to the instance key). */
30
+ readonly label: string;
31
+ /** The engine lifecycle status (a bare string, e.g. COMPLETED / THINKING / IDLE). */
32
+ readonly status: string;
33
+ /** The owning process-instance key. */
34
+ readonly processInstanceKey: string;
35
+ /** The BPMN element id (AI-agent task) that owns the instance, when reported. */
36
+ readonly elementId?: string;
37
+ /** A compact human token/call rollup (e.g. "1.2k in · 340 out · 3 calls · 5 tools"), when metrics exist. */
38
+ readonly metrics?: string;
39
+ /** When the run was captured — completionDate when sealed, else lastUpdatedDate, else creationDate. */
40
+ readonly capturedAt?: string;
41
+ }
42
+
43
+ /** The full renderable historical-sessions list view. */
44
+ export interface AgentSessionsView {
45
+ readonly sessions: readonly AgentSessionView[];
46
+ readonly count: number;
47
+ }
48
+
49
+ /** One tool call in a rendered turn. */
50
+ export interface AgentTurnToolCallView {
51
+ readonly toolCallId: string;
52
+ readonly toolName: string;
53
+ readonly elementId?: string;
54
+ }
55
+
56
+ /** One conversation turn in the renderable history. */
57
+ export interface AgentTurnView {
58
+ readonly historyItemKey: string;
59
+ readonly loopIteration: number;
60
+ readonly role: AgentHistoryRecord["role"];
61
+ /** The turn's text content blocks, joined newest-in-order (empty when the turn is non-textual). */
62
+ readonly text: string;
63
+ readonly toolCalls: readonly AgentTurnToolCallView[];
64
+ /** A compact per-turn token/duration rollup, when metrics exist. */
65
+ readonly metrics?: string;
66
+ }
67
+
68
+ /** The full renderable history for one agent instance: its rolled-up header + ordered turns. */
69
+ export interface AgentHistoryView {
70
+ readonly agentInstanceKey: string;
71
+ /** The owning instance summary row, when the engine still reports it. */
72
+ readonly instance?: AgentSessionView;
73
+ readonly turns: readonly AgentTurnView[];
74
+ readonly count: number;
75
+ }
76
+
77
+ /** A single stable human label for an agent run's process / element (empty parts dropped). */
78
+ function instanceLabel(i: AgentInstanceReport): string {
79
+ const parts: string[] = [];
80
+ if (i.processDefinitionId !== undefined && i.processDefinitionId !== "") parts.push(i.processDefinitionId);
81
+ if (i.elementId !== undefined && i.elementId !== "") parts.push(i.elementId);
82
+ if (i.processInstanceKey !== "") parts.push(`inst ${i.processInstanceKey}`);
83
+ if (parts.length > 0) return parts.join(" \u00b7 ");
84
+ return i.agentInstanceKey;
85
+ }
86
+
87
+ /** Render a token count compactly (e.g. 1234 -> "1.2k"), stable and locale-free. */
88
+ function humanCount(n: number): string {
89
+ if (!Number.isFinite(n) || n < 0) return "0";
90
+ if (n < 1000) return String(n);
91
+ if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
92
+ return `${(n / 1_000_000).toFixed(1)}M`;
93
+ }
94
+
95
+ /** Render a duration (ms) compactly (e.g. "1.2s", "340ms"), or undefined when absent/zero. */
96
+ function humanMs(ms: number | undefined): string | undefined {
97
+ if (ms === undefined || !Number.isFinite(ms) || ms <= 0) return undefined;
98
+ if (ms < 1000) return `${Math.round(ms)}ms`;
99
+ return `${(ms / 1000).toFixed(1)}s`;
100
+ }
101
+
102
+ function instanceMetrics(i: AgentInstanceReport): string | undefined {
103
+ const m = i.metrics;
104
+ if (m === undefined) return undefined;
105
+ return `${humanCount(m.inputTokens)} in \u00b7 ${humanCount(m.outputTokens)} out \u00b7 ${m.modelCalls} calls \u00b7 ${m.toolCalls} tools`;
106
+ }
107
+
108
+ function turnMetrics(r: AgentHistoryRecord): string | undefined {
109
+ const m = r.metrics;
110
+ if (m === undefined) return undefined;
111
+ const dur = humanMs(m.durationMs);
112
+ const base = `${humanCount(m.inputTokens)} in \u00b7 ${humanCount(m.outputTokens)} out`;
113
+ return dur !== undefined ? `${base} \u00b7 ${dur}` : base;
114
+ }
115
+
116
+ /** The textual content of a turn: TEXT blocks joined in order (non-textual/empty blocks dropped). */
117
+ function turnText(r: AgentHistoryRecord): string {
118
+ const texts: string[] = [];
119
+ for (const b of r.content) {
120
+ if (b.contentType === "TEXT" && b.text !== undefined && b.text !== "") texts.push(b.text);
121
+ }
122
+ return texts.join("\n");
123
+ }
124
+
125
+ /** Project one engine {@link AgentInstanceReport} onto a renderable {@link AgentSessionView}. */
126
+ export function agentSessionView(i: AgentInstanceReport): AgentSessionView {
127
+ const metrics = instanceMetrics(i);
128
+ const capturedAt = i.completionDate ?? i.lastUpdatedDate ?? i.creationDate;
129
+ return {
130
+ agentInstanceKey: i.agentInstanceKey,
131
+ label: instanceLabel(i),
132
+ status: i.status,
133
+ processInstanceKey: i.processInstanceKey,
134
+ ...(i.elementId !== undefined && i.elementId !== "" ? { elementId: i.elementId } : {}),
135
+ ...(metrics !== undefined ? { metrics } : {}),
136
+ ...(capturedAt !== undefined && capturedAt !== "" ? { capturedAt } : {}),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Derive the renderable historical-sessions list from the engine AgentInstance list report.
142
+ *
143
+ * Pure and total: re-sorts newest-captured-first (stable on the instance key) so the view is
144
+ * diff-friendly regardless of the report's incoming order; no input mutates and no I/O happens.
145
+ */
146
+ export function agentSessionsView(report: AgentInstanceListReport): AgentSessionsView {
147
+ const sessions = report.instances
148
+ .map(agentSessionView)
149
+ .sort((a, b) => {
150
+ const byTime = (b.capturedAt ?? "").localeCompare(a.capturedAt ?? "");
151
+ return byTime !== 0 ? byTime : a.agentInstanceKey.localeCompare(b.agentInstanceKey);
152
+ });
153
+ return { sessions, count: sessions.length };
154
+ }
155
+
156
+ /** Project one engine {@link AgentHistoryRecord} onto a renderable {@link AgentTurnView}. */
157
+ export function agentTurnView(r: AgentHistoryRecord): AgentTurnView {
158
+ const metrics = turnMetrics(r);
159
+ return {
160
+ historyItemKey: r.historyItemKey,
161
+ loopIteration: r.loopIteration,
162
+ role: r.role,
163
+ text: turnText(r),
164
+ toolCalls: r.toolCalls.map((c) => ({
165
+ toolCallId: c.toolCallId,
166
+ toolName: c.toolName,
167
+ ...(c.elementId !== undefined && c.elementId !== "" ? { elementId: c.elementId } : {}),
168
+ })),
169
+ ...(metrics !== undefined ? { metrics } : {}),
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Derive one agent instance's renderable history from the engine AgentHistory report. The transport
175
+ * already orders records (loopIteration, then creation-ordered key); this projection preserves that
176
+ * order. Pure and total; no I/O.
177
+ */
178
+ export function agentHistoryView(report: AgentHistoryReport): AgentHistoryView {
179
+ const turns = report.records.map(agentTurnView);
180
+ return {
181
+ agentInstanceKey: report.agentInstanceKey,
182
+ ...(report.instance !== undefined ? { instance: agentSessionView(report.instance) } : {}),
183
+ turns,
184
+ count: turns.length,
185
+ };
186
+ }
@@ -5,6 +5,27 @@
5
5
  //
6
6
  // The DEMAND×supply matrix, missing-agent-type reds, and diversity-SLO lights are OUT OF SCOPE for
7
7
  // this epic (#142) and deferred to the paired enrolment epic #152.
8
+
9
+ export {
10
+ type AgentHistoryDom,
11
+ type AgentSessionsDom,
12
+ type RenderAgentSessionsOptions,
13
+ renderAgentHistory,
14
+ renderAgentSessions,
15
+ } from "./agent-history-render.ts";
16
+ export {
17
+ type AgentHistoryReport,
18
+ type AgentHistoryView,
19
+ type AgentInstanceListReport,
20
+ type AgentSessionsView,
21
+ type AgentSessionView,
22
+ type AgentTurnToolCallView,
23
+ type AgentTurnView,
24
+ agentHistoryView,
25
+ agentSessionsView,
26
+ agentSessionView,
27
+ agentTurnView,
28
+ } from "./agent-history-view.ts";
8
29
  export {
9
30
  type CockpitRoute,
10
31
  parseCockpitRoute,
@@ -78,6 +78,7 @@ function fetchStub(replay?: unknown) {
78
78
  const ok = (body: unknown) => Promise.resolve({ ok: true, status: 200, json: async () => body });
79
79
  if (url.includes("/supply")) return ok(SUPPLY);
80
80
  if (replay !== undefined && /[?&]stream=/.test(url)) return ok(replay);
81
+ if (url.includes("/agent-instances")) return ok({ count: 0, instances: [] });
81
82
  if (url.includes("/transcripts")) return ok({ sessions: [] });
82
83
  return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
83
84
  };
@@ -97,7 +98,13 @@ test("the rendered transcript region sits directly beneath the Workers — suppl
97
98
  const handle = mountCockpit(document.getElementById("root"), OPTS);
98
99
  const shell = document.querySelector(".cockpit-shell");
99
100
  const order = [...(shell?.children ?? [])].map((c: { className: string }) => c.className);
100
- assertEquals(order, ["cockpit-supply-region", "cockpit-terminal", "cockpit-past-region"]);
101
+ assertEquals(order, [
102
+ "cockpit-supply-region",
103
+ "cockpit-terminal",
104
+ "cockpit-past-region",
105
+ "cockpit-agent-region",
106
+ "cockpit-agent-detail-region",
107
+ ]);
101
108
  handle.dispose();
102
109
  } finally {
103
110
  restore();
@@ -213,3 +220,128 @@ test("#744: replay fetches the proxy-safe ?stream= query form — a slash-bearin
213
220
  restore();
214
221
  }
215
222
  });
223
+
224
+ // Engine-native SETTLED agent-history panel (issue #745/#747): the browser twin renders the run list
225
+ // from /agent-instances and a selected run's turns from /agent-instances/{key}/history, keyed by the
226
+ // agent-instance key — never a relay stream id. mount.js has no byte-drift guard, so this behaviour
227
+ // test is the browser twin's coverage.
228
+ test("agent-history panel renders the engine run list and a selected run's turns", async () => {
229
+ const instances = {
230
+ count: 1,
231
+ instances: [
232
+ {
233
+ agentInstanceKey: "ai-42",
234
+ status: "COMPLETED",
235
+ processInstanceKey: "pi-1",
236
+ elementId: "implement-task",
237
+ completionDate: "2024-01-01T00:00:00Z",
238
+ metrics: { inputTokens: 1500, outputTokens: 340, modelCalls: 3, toolCalls: 2 },
239
+ },
240
+ ],
241
+ };
242
+ const history = {
243
+ agentInstanceKey: "ai-42",
244
+ count: 1,
245
+ instance: instances.instances[0],
246
+ records: [
247
+ {
248
+ historyItemKey: "h-0",
249
+ agentInstanceKey: "ai-42",
250
+ loopIteration: 0,
251
+ role: "ASSISTANT",
252
+ commitStatus: "COMMITTED",
253
+ content: [{ contentType: "TEXT", text: "did the thing" }],
254
+ toolCalls: [{ toolCallId: "t-1", toolName: "grep", elementId: "tool", arguments: {} }],
255
+ metrics: { inputTokens: 12, outputTokens: 4, reasoningTokenCount: 0, cacheCreationTokenCount: 0, cacheReadTokenCount: 0, durationMs: 900 },
256
+ },
257
+ ],
258
+ };
259
+ const requested: string[] = [];
260
+ const restore = installEnv((url) => {
261
+ const ok = (body: unknown) => Promise.resolve({ ok: true, status: 200, json: async () => body });
262
+ if (url.includes("/supply")) return ok(SUPPLY);
263
+ if (/\/agent-instances\/[^/]+\/history/.test(url)) {
264
+ requested.push(url);
265
+ return ok(history);
266
+ }
267
+ if (url.includes("/agent-instances")) return ok(instances);
268
+ if (url.includes("/transcripts")) return ok({ sessions: [] });
269
+ return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
270
+ });
271
+ try {
272
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
273
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
274
+ try {
275
+ await handle.refresh();
276
+ // refresh() kicks the agent-history list fetch fire-and-forget; let it settle.
277
+ await new Promise((r) => setTimeout(r, 0));
278
+ const row = document.querySelector('.cockpit-agent-session[data-agent-instance-key="ai-42"]');
279
+ assert(row != null, "the engine agent run is listed");
280
+
281
+ await handle.viewAgentHistory("ai-42");
282
+ assert(requested.some((u) => u.includes("/agent-instances/ai-42/history")), `history fetched by key (saw: ${requested.join(", ")})`);
283
+ const transcript = document.querySelector('.cockpit-agent-transcript[data-agent-instance-key="ai-42"]');
284
+ assert(transcript != null, "the selected run's history rendered");
285
+ assert((transcript?.textContent ?? "").includes("did the thing"), "the turn text rendered");
286
+ assert((transcript?.textContent ?? "").includes("grep"), "the tool call rendered");
287
+ } finally {
288
+ handle.dispose();
289
+ }
290
+ } finally {
291
+ restore();
292
+ }
293
+ });
294
+
295
+ // #745 — the browser twin must treat an empty-string tool-call elementId as ABSENT (matching the
296
+ // server SSOT `present()` in app/agentic/agent-history.ts, which drops empty elementIds), rendering
297
+ // just the tool name — never `toolName ()`.
298
+ test("agent-history tool call with an empty-string elementId renders no empty () suffix", async () => {
299
+ const instances = {
300
+ count: 1,
301
+ instances: [{ agentInstanceKey: "ai-77", status: "COMPLETED", processInstanceKey: "pi-1", elementId: "impl" }],
302
+ };
303
+ const history = {
304
+ agentInstanceKey: "ai-77",
305
+ count: 1,
306
+ instance: instances.instances[0],
307
+ records: [
308
+ {
309
+ historyItemKey: "h-0",
310
+ agentInstanceKey: "ai-77",
311
+ loopIteration: 0,
312
+ role: "ASSISTANT",
313
+ commitStatus: "COMMITTED",
314
+ content: [{ contentType: "TEXT", text: "did the thing" }],
315
+ toolCalls: [
316
+ { toolCallId: "t-1", toolName: "grep", elementId: "", arguments: {} },
317
+ { toolCallId: "t-2", toolName: "view", elementId: "tool", arguments: {} },
318
+ ],
319
+ },
320
+ ],
321
+ };
322
+ const restore = installEnv((url) => {
323
+ const ok = (body: unknown) => Promise.resolve({ ok: true, status: 200, json: async () => body });
324
+ if (url.includes("/supply")) return ok(SUPPLY);
325
+ if (/\/agent-instances\/[^/]+\/history/.test(url)) return ok(history);
326
+ if (url.includes("/agent-instances")) return ok(instances);
327
+ if (url.includes("/transcripts")) return ok({ sessions: [] });
328
+ return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
329
+ });
330
+ try {
331
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
332
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
333
+ try {
334
+ await handle.refresh();
335
+ await new Promise((r) => setTimeout(r, 0));
336
+ await handle.viewAgentHistory("ai-77");
337
+ const tools = [...document.querySelectorAll(".cockpit-agent-turn-tool")].map((n) => n.textContent ?? "");
338
+ assert(tools.includes("grep"), `empty elementId renders bare tool name (saw: ${tools.join(", ")})`);
339
+ assert(!tools.some((t) => t.includes("()")), `no empty () suffix rendered (saw: ${tools.join(", ")})`);
340
+ assert(tools.includes("view (tool)"), `a present elementId still renders its suffix (saw: ${tools.join(", ")})`);
341
+ } finally {
342
+ handle.dispose();
343
+ }
344
+ } finally {
345
+ restore();
346
+ }
347
+ });
@@ -0,0 +1,144 @@
1
+ // Unit tests for the SUPPLY cockpit boot layer's engine-native AGENT-HISTORY panel (issue #745/#747).
2
+ //
3
+ // The testable heart of the consumer half: the cockpit renders a settled agent-history list (sourced
4
+ // from engine searchAgentInstances) beside the live supply + relay past-sessions panels; selecting a
5
+ // run renders its ordered conversation turns + metrics (sourced from engine searchAgentInstanceHistory)
6
+ // keyed by agentInstanceKey — never a relay stream id. No browser, no engine, no socket.
7
+ import assert from "node:assert/strict";
8
+ import { test } from "node:test";
9
+ import { FakeDocument, FakeElement, FakeSocket } from "../../../test/agentic-cockpit-doubles.ts";
10
+ import type {
11
+ AgentHistory as AgentHistoryReport,
12
+ AgentInstanceList as AgentInstanceListReport,
13
+ } from "../../../nano-generated/api-io.d.ts";
14
+ import { bootSupplyCockpit, type SupplyCockpitEnv } from "./supply-boot.ts";
15
+ import type { SupplyReport } from "./supply-view.ts";
16
+
17
+ const flush = () => new Promise<void>((resolve) => setImmediate(resolve));
18
+
19
+ const supply: SupplyReport = { count: 0, workers: [], leaves: [] };
20
+
21
+ const instances: AgentInstanceListReport = {
22
+ count: 2,
23
+ instances: [
24
+ { agentInstanceKey: "ai-old", status: "COMPLETED", processInstanceKey: "pi-1", elementId: "implement-task", completionDate: "2024-01-01T00:00:00Z", metrics: { inputTokens: 100, outputTokens: 20, modelCalls: 2, toolCalls: 1 } },
25
+ { agentInstanceKey: "ai-new", status: "IDLE", processInstanceKey: "pi-2", lastUpdatedDate: "2024-02-01T00:00:00Z" },
26
+ ],
27
+ };
28
+
29
+ const history: AgentHistoryReport = {
30
+ agentInstanceKey: "ai-old",
31
+ count: 1,
32
+ instance: instances.instances[0],
33
+ records: [
34
+ { historyItemKey: "h-0", agentInstanceKey: "ai-old", loopIteration: 0, role: "ASSISTANT", commitStatus: "COMMITTED", content: [{ contentType: "TEXT", text: "did the thing" }], toolCalls: [] },
35
+ ],
36
+ };
37
+
38
+ interface Rig {
39
+ readonly env: SupplyCockpitEnv;
40
+ readonly host: FakeElement;
41
+ requestedHistoryKey: string | undefined;
42
+ errors: unknown[];
43
+ }
44
+
45
+ function rig(withAgentHistory = true): Rig {
46
+ const host = new FakeElement("body");
47
+ const state: Rig = {
48
+ host,
49
+ requestedHistoryKey: undefined,
50
+ errors: [],
51
+ env: {
52
+ host,
53
+ doc: new FakeDocument(),
54
+ fetchSupply: () => Promise.resolve(supply),
55
+ ...(withAgentHistory
56
+ ? {
57
+ fetchAgentInstances: () => Promise.resolve(instances),
58
+ fetchAgentHistory: (agentInstanceKey: string) => {
59
+ state.requestedHistoryKey = agentInstanceKey;
60
+ return Promise.resolve(history);
61
+ },
62
+ }
63
+ : {}),
64
+ connectRelay: () => new FakeSocket(),
65
+ createTerminal: (terminalHost) => {
66
+ terminalHost.appendChild(new FakeElement("pre"));
67
+ return { write: () => {}, dispose: () => {} };
68
+ },
69
+ setTimer: () => 0,
70
+ clearTimer: () => {},
71
+ onError: (err) => state.errors.push(err),
72
+ },
73
+ };
74
+ return state;
75
+ }
76
+
77
+ test("refresh renders the engine agent-history list, sorted newest-first", async () => {
78
+ const r = rig();
79
+ const cockpit = bootSupplyCockpit(r.env);
80
+ await cockpit.refresh();
81
+ await flush();
82
+ const rows = r.host.byClass("cockpit-agent-session");
83
+ assert.equal(rows.length, 2, "one row per engine agent instance");
84
+ assert.equal(rows[0]?.getAttribute("data-agent-instance-key"), "ai-new", "newest-updated run first");
85
+ assert.equal(r.host.byData("summary", "agent-history").length, 1);
86
+ assert.deepEqual(r.errors, []);
87
+ });
88
+
89
+ test("selecting a run renders its engine history keyed by agentInstanceKey", async () => {
90
+ const r = rig();
91
+ const cockpit = bootSupplyCockpit(r.env);
92
+ await cockpit.refresh();
93
+ await flush();
94
+
95
+ await cockpit.viewAgentHistory("ai-old");
96
+ await flush();
97
+ assert.equal(r.requestedHistoryKey, "ai-old", "history fetched by agent-instance key, not a stream id");
98
+ assert.equal(cockpit.currentAgentInstanceKey, "ai-old");
99
+ const transcript = r.host.byClass("cockpit-agent-transcript")[0];
100
+ assert.equal(transcript?.getAttribute("data-agent-instance-key"), "ai-old");
101
+ assert.equal(r.host.byClass("cockpit-agent-turn-text")[0]?.text(), "did the thing");
102
+ assert.deepEqual(r.errors, []);
103
+ });
104
+
105
+ test("clicking a run button drives viewAgentHistory", async () => {
106
+ const r = rig();
107
+ const cockpit = bootSupplyCockpit(r.env);
108
+ await cockpit.refresh();
109
+ await flush();
110
+ const button = r.host.byClass("cockpit-agent-select").find((b) => b.getAttribute("data-agent-instance-key") === "ai-old");
111
+ button?.dispatch("click");
112
+ await flush();
113
+ assert.equal(cockpit.currentAgentInstanceKey, "ai-old");
114
+ });
115
+
116
+ test("the embedded shell renders the terminal directly beneath the supply list, matching mount.js", async () => {
117
+ const r = rig();
118
+ bootSupplyCockpit(r.env);
119
+ const shell = r.host.byClass("cockpit-shell")[0];
120
+ const order = (shell?.children ?? []).map((c) => c.className);
121
+ assert.deepEqual(order, [
122
+ "cockpit-supply-region",
123
+ "cockpit-terminal",
124
+ "cockpit-agent-region",
125
+ "cockpit-agent-detail-region",
126
+ ]);
127
+ });
128
+
129
+ test("no agent-history panel is rendered when the engine read endpoints are unwired", async () => {
130
+ const r = rig(false);
131
+ const cockpit = bootSupplyCockpit(r.env);
132
+ await cockpit.refresh();
133
+ await flush();
134
+ assert.equal(r.host.byClass("cockpit-agent-region").length, 0);
135
+ assert.equal(cockpit.currentAgentInstanceKey, undefined);
136
+ });
137
+
138
+ test("fetchAgentInstances without fetchAgentHistory fails loudly (matched-pair guard)", () => {
139
+ const r = rig(false);
140
+ assert.throws(
141
+ () => bootSupplyCockpit({ ...r.env, fetchAgentInstances: () => Promise.resolve(instances) }),
142
+ /fetchAgentInstances and fetchAgentHistory must be provided together/,
143
+ );
144
+ });