@nanobpm/nano-workforce 0.183.1 → 0.184.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 (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/app/agentic/agent-history.test.ts +165 -0
  3. package/app/agentic/agent-history.ts +211 -0
  4. package/app/agentic/claim-registry.test.ts +3 -2
  5. package/app/agentic/claim-registry.ts +8 -6
  6. package/app/agentic/cockpit/agent-history-render.test.ts +85 -0
  7. package/app/agentic/cockpit/agent-history-render.ts +168 -0
  8. package/app/agentic/cockpit/agent-history-view.test.ts +104 -0
  9. package/app/agentic/cockpit/agent-history-view.ts +186 -0
  10. package/app/agentic/cockpit/index.ts +21 -0
  11. package/app/agentic/cockpit/mount.test.ts +133 -1
  12. package/app/agentic/cockpit/supply-boot-agent-history.test.ts +144 -0
  13. package/app/agentic/cockpit/supply-boot.ts +134 -1
  14. package/app/agentic/cockpit/supply-view.ts +3 -3
  15. package/app/agentic/cockpit/transcript-view.ts +1 -1
  16. package/app/agentic/correlation-store.test.ts +14 -10
  17. package/app/agentic/correlation-store.ts +4 -3
  18. package/app/agentic/correlation.test.ts +24 -16
  19. package/app/agentic/correlation.ts +25 -12
  20. package/app/agentic/families/claim.family.test.ts +2 -1
  21. package/app/agentic/families/relay.family.test.ts +74 -73
  22. package/app/agentic/families/relay.family.ts +24 -21
  23. package/app/agentic/transcript-read.test.ts +108 -17
  24. package/app/agentic/transcript-read.ts +41 -7
  25. package/app/contracts.ts +10 -2
  26. package/app/mcpToolSurface.ts +8 -1
  27. package/db/migrations/101_agentic_history_read_expand.sql +34 -0
  28. package/openapi.yaml +360 -0
  29. package/operations/agentHistoryEndpoints.test.ts +106 -0
  30. package/operations/getAgentInstanceHistory.ts +37 -0
  31. package/operations/getAgenticSupply.test.ts +41 -2
  32. package/operations/getAgenticSupply.ts +7 -5
  33. package/operations/getAgenticTranscript.test.ts +5 -4
  34. package/operations/listAgentInstances.ts +42 -0
  35. package/operations/listAgenticTranscripts.test.ts +10 -9
  36. package/package.json +3 -3
  37. package/pages/cockpit/cockpit.css +104 -0
  38. package/pages/cockpit/mount.js +286 -3
  39. package/test/agentic-e2e.test.ts +4 -1
@@ -0,0 +1,168 @@
1
+ // The cockpit engine-native "agent history" DOM renderer (ADR 0056, issue #745/#747, umbrella #746).
2
+ //
3
+ // Renders an {@link AgentSessionsView} (the settled agent-run list, sourced from engine
4
+ // `searchAgentInstances`) and a selected instance's {@link AgentHistoryView} (its ordered conversation
5
+ // turns + per-turn / instance metrics, sourced from engine `searchAgentInstanceHistory`) into host
6
+ // elements. This is the CONSUMER render surface of the durable-agent-transcript work: the HISTORICAL
7
+ // transcript is a STRUCTURED conversation (roles / text / tool calls / metrics) read from engine truth,
8
+ // keyed by `agentInstanceKey` — NOT the ANSI terminal replay of a relay stream (`./transcript-render.ts`),
9
+ // which stays the LIVE overlay only.
10
+ //
11
+ // Like the relay renderer it builds against the structural {@link ElementLike} / {@link DocumentLike}
12
+ // subset (reused from the package) rather than the global `document`, so the real DOM satisfies it at
13
+ // runtime AND a plain in-memory fake satisfies it for DOM-free Node tests. It draws only the volatile
14
+ // history; the host region is owned by the boot layer.
15
+ import type { DocumentLike, ElementLike } from "@nanobpm/agentic/cockpit";
16
+ import type { AgentHistoryView, AgentSessionsView, AgentSessionView, AgentTurnView } from "./agent-history-view.ts";
17
+
18
+ export interface RenderAgentSessionsOptions {
19
+ /** Called with an instance's key when the operator selects it to view its history. */
20
+ readonly onSelect?: (agentInstanceKey: string) => void;
21
+ /** The instance currently being viewed, if any — highlighted in the list. */
22
+ readonly activeInstanceKey?: string;
23
+ /** Panel title. Defaults to the historical-sessions label. */
24
+ readonly title?: string;
25
+ /** Empty-state copy. */
26
+ readonly emptyText?: string;
27
+ }
28
+
29
+ export interface AgentSessionsDom {
30
+ readonly root: ElementLike;
31
+ }
32
+
33
+ export interface AgentHistoryDom {
34
+ readonly root: ElementLike;
35
+ }
36
+
37
+ function el(doc: DocumentLike, tag: string, className?: string, text?: string): ElementLike {
38
+ const node = doc.createElement(tag);
39
+ if (className !== undefined) node.className = className;
40
+ if (text !== undefined) node.textContent = text;
41
+ return node;
42
+ }
43
+
44
+ function sessionRow(doc: DocumentLike, s: AgentSessionView, options: RenderAgentSessionsOptions): ElementLike {
45
+ const row = el(doc, "tr", "cockpit-agent-session");
46
+ row.setAttribute("data-agent-instance-key", s.agentInstanceKey);
47
+ row.setAttribute("data-status", s.status);
48
+ if (options.activeInstanceKey === s.agentInstanceKey) row.setAttribute("data-active", "true");
49
+
50
+ const nameCell = el(doc, "td", "cockpit-td cockpit-agent-name");
51
+ const button = el(doc, "button", "cockpit-agent-select", s.label);
52
+ button.setAttribute("type", "button");
53
+ button.setAttribute("data-agent-instance-key", s.agentInstanceKey);
54
+ const onSelect = options.onSelect;
55
+ if (onSelect !== undefined) button.addEventListener("click", () => onSelect(s.agentInstanceKey));
56
+ nameCell.appendChild(button);
57
+ row.appendChild(nameCell);
58
+
59
+ row.appendChild(el(doc, "td", "cockpit-td cockpit-agent-status", s.status));
60
+ row.appendChild(el(doc, "td", "cockpit-td cockpit-agent-metrics", s.metrics ?? ""));
61
+ row.appendChild(el(doc, "td", "cockpit-td cockpit-agent-captured", s.capturedAt ?? ""));
62
+ return row;
63
+ }
64
+
65
+ /**
66
+ * Render the historical agent-sessions list `view` into `host`, replacing whatever was there.
67
+ * Idempotent: call again on every refresh to reflect the latest engine snapshot.
68
+ */
69
+ export function renderAgentSessions(
70
+ host: ElementLike,
71
+ doc: DocumentLike,
72
+ view: AgentSessionsView,
73
+ options: RenderAgentSessionsOptions = {},
74
+ ): AgentSessionsDom {
75
+ host.replaceChildren();
76
+ const root = el(doc, "div", "cockpit-agent-history");
77
+ root.setAttribute("data-session-count", String(view.count));
78
+
79
+ const header = el(doc, "header", "cockpit-agent-header");
80
+ header.appendChild(el(doc, "h2", "cockpit-agent-title", options.title ?? "Agent history"));
81
+ const summary = el(doc, "span", "cockpit-agent-summary", String(view.count));
82
+ summary.setAttribute("data-summary", "agent-history");
83
+ header.appendChild(summary);
84
+ root.appendChild(header);
85
+
86
+ if (view.count === 0) {
87
+ const empty = el(doc, "div", "cockpit-agent-empty", options.emptyText ?? "No agent runs recorded yet.");
88
+ empty.setAttribute("data-empty", "true");
89
+ root.appendChild(empty);
90
+ host.appendChild(root);
91
+ return { root };
92
+ }
93
+
94
+ const table = el(doc, "table", "cockpit-agent-table");
95
+ const thead = el(doc, "thead", "cockpit-agent-thead");
96
+ const head = el(doc, "tr", "cockpit-agent-head");
97
+ for (const label of ["run", "status", "metrics", "captured"]) head.appendChild(el(doc, "th", "cockpit-th", label));
98
+ thead.appendChild(head);
99
+ table.appendChild(thead);
100
+ const tbody = el(doc, "tbody", "cockpit-agent-tbody");
101
+ for (const s of view.sessions) tbody.appendChild(sessionRow(doc, s, options));
102
+ table.appendChild(tbody);
103
+ root.appendChild(table);
104
+
105
+ host.appendChild(root);
106
+ return { root };
107
+ }
108
+
109
+ function turnBlock(doc: DocumentLike, t: AgentTurnView): ElementLike {
110
+ const block = el(doc, "div", "cockpit-agent-turn");
111
+ block.setAttribute("data-history-item-key", t.historyItemKey);
112
+ block.setAttribute("data-role", t.role);
113
+ block.setAttribute("data-loop-iteration", String(t.loopIteration));
114
+
115
+ const meta = el(doc, "div", "cockpit-agent-turn-meta");
116
+ meta.appendChild(el(doc, "span", "cockpit-agent-turn-role", t.role));
117
+ meta.appendChild(el(doc, "span", "cockpit-agent-turn-iter", `#${t.loopIteration}`));
118
+ if (t.metrics !== undefined) meta.appendChild(el(doc, "span", "cockpit-agent-turn-metrics", t.metrics));
119
+ block.appendChild(meta);
120
+
121
+ if (t.text !== "") block.appendChild(el(doc, "pre", "cockpit-agent-turn-text", t.text));
122
+
123
+ if (t.toolCalls.length > 0) {
124
+ const tools = el(doc, "ul", "cockpit-agent-turn-tools");
125
+ for (const call of t.toolCalls) {
126
+ const li = el(doc, "li", "cockpit-agent-turn-tool", call.elementId !== undefined ? `${call.toolName} (${call.elementId})` : call.toolName);
127
+ li.setAttribute("data-tool-call-id", call.toolCallId);
128
+ tools.appendChild(li);
129
+ }
130
+ block.appendChild(tools);
131
+ }
132
+ return block;
133
+ }
134
+
135
+ /**
136
+ * Render one instance's conversation history `view` into `host`, replacing whatever was there.
137
+ * Idempotent. A null/empty history renders an explicit empty state (read-as-absence).
138
+ */
139
+ export function renderAgentHistory(host: ElementLike, doc: DocumentLike, view: AgentHistoryView): AgentHistoryDom {
140
+ host.replaceChildren();
141
+ const root = el(doc, "div", "cockpit-agent-transcript");
142
+ root.setAttribute("data-agent-instance-key", view.agentInstanceKey);
143
+ root.setAttribute("data-turn-count", String(view.count));
144
+
145
+ const header = el(doc, "header", "cockpit-agent-transcript-header");
146
+ header.appendChild(el(doc, "h3", "cockpit-agent-transcript-title", view.instance?.label ?? view.agentInstanceKey));
147
+ if (view.instance?.metrics !== undefined) {
148
+ const m = el(doc, "span", "cockpit-agent-transcript-metrics", view.instance.metrics);
149
+ m.setAttribute("data-summary", "agent-instance-metrics");
150
+ header.appendChild(m);
151
+ }
152
+ root.appendChild(header);
153
+
154
+ if (view.count === 0) {
155
+ const empty = el(doc, "div", "cockpit-agent-transcript-empty", "No history for this run.");
156
+ empty.setAttribute("data-empty", "true");
157
+ root.appendChild(empty);
158
+ host.appendChild(root);
159
+ return { root };
160
+ }
161
+
162
+ const turns = el(doc, "div", "cockpit-agent-turns");
163
+ for (const t of view.turns) turns.appendChild(turnBlock(doc, t));
164
+ root.appendChild(turns);
165
+
166
+ host.appendChild(root);
167
+ return { root };
168
+ }
@@ -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
+ });