@nanobpm/nano-workforce 0.183.2 → 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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.184.0](https://github.com/nanobpm/nano-workforce/compare/v0.183.2...v0.184.0) (2026-09-07)
2
+
3
+ ### Features
4
+
5
+ * **agent:** consume engine-native AgentInstance/AgentHistory for cockpit history ([#756](https://github.com/nanobpm/nano-workforce/issues/756)) ([555463f](https://github.com/nanobpm/nano-workforce/commit/555463fb4b65f3aaf3aa27a36b518855dddce5b8)), closes [#745](https://github.com/nanobpm/nano-workforce/issues/745) [#747](https://github.com/nanobpm/nano-workforce/issues/747) [#755](https://github.com/nanobpm/nano-workforce/issues/755) [jwulf/c8ctl-plugin-nano#195](https://github.com/jwulf/c8ctl-plugin-nano/issues/195) [#194](https://github.com/nanobpm/nano-workforce/issues/194) [745/#747](https://github.com/745/nano-workforce/issues/747) [#refreshAgentHistory](https://github.com/nanobpm/nano-workforce/issues/refreshAgentHistory) [745/#747](https://github.com/745/nano-workforce/issues/747)
6
+
1
7
  ## [0.183.2](https://github.com/nanobpm/nano-workforce/compare/v0.183.1...v0.183.2) (2026-09-07)
2
8
 
3
9
  ### Bug Fixes
@@ -0,0 +1,165 @@
1
+ // Pure projection tests for the engine-native AgentInstance/AgentHistory READ path (issue #745/#747).
2
+ // Exercises app/agentic/agent-history.ts against a fake AgentHistoryReader — no engine, no I/O — so the
3
+ // engine→wire projection, keying, ordering, optional-field dropping, and read-as-absence are pinned on
4
+ // Node. Behavioural parity against a LIVE engine is validated separately; the testkit WASM double
5
+ // records nothing (read-as-absence), which the boot test asserts.
6
+ import assert from "node:assert/strict";
7
+ import { test } from "node:test";
8
+ import type {
9
+ AgentHistoryFilter,
10
+ AgentHistoryRecord,
11
+ AgentInstanceFilter,
12
+ AgentInstanceSummary,
13
+ } from "@nanobpm/urban";
14
+ import {
15
+ type AgentHistoryReader,
16
+ listAgentInstances,
17
+ readAgentHistory,
18
+ toWireInstance,
19
+ toWireRecord,
20
+ } from "./agent-history.ts";
21
+
22
+ class FakeReader implements AgentHistoryReader {
23
+ instanceFilter: AgentInstanceFilter | undefined;
24
+ historyFilter: AgentHistoryFilter | undefined;
25
+ historyKey: string | undefined;
26
+ readonly #instances: readonly AgentInstanceSummary[];
27
+ readonly #history: readonly AgentHistoryRecord[];
28
+ readonly #byKey: Record<string, AgentInstanceSummary>;
29
+ constructor(
30
+ instances: readonly AgentInstanceSummary[],
31
+ history: readonly AgentHistoryRecord[] = [],
32
+ byKey: Record<string, AgentInstanceSummary> = {},
33
+ ) {
34
+ this.#instances = instances;
35
+ this.#history = history;
36
+ this.#byKey = byKey;
37
+ }
38
+ async searchAgentInstances(filter?: AgentInstanceFilter): Promise<readonly AgentInstanceSummary[]> {
39
+ this.instanceFilter = filter;
40
+ return this.#instances;
41
+ }
42
+ async searchAgentInstanceHistory(
43
+ agentInstanceKey: string,
44
+ filter?: AgentHistoryFilter,
45
+ ): Promise<readonly AgentHistoryRecord[]> {
46
+ this.historyKey = agentInstanceKey;
47
+ this.historyFilter = filter;
48
+ return this.#history;
49
+ }
50
+ async getAgentInstance(agentInstanceKey: string): Promise<AgentInstanceSummary | null> {
51
+ return this.#byKey[agentInstanceKey] ?? null;
52
+ }
53
+ }
54
+
55
+ const instance = (over: Partial<AgentInstanceSummary> & { agentInstanceKey: string }): AgentInstanceSummary => ({
56
+ status: "COMPLETED",
57
+ processInstanceKey: "pi-1",
58
+ ...over,
59
+ });
60
+
61
+ const record = (over: Partial<AgentHistoryRecord> & { historyItemKey: string }): AgentHistoryRecord => ({
62
+ agentInstanceKey: "ai-1",
63
+ loopIteration: 0,
64
+ role: "ASSISTANT",
65
+ content: [],
66
+ toolCalls: [],
67
+ commitStatus: "COMMITTED",
68
+ ...over,
69
+ });
70
+
71
+ test("toWireInstance drops unreported optional fields and copies metrics", () => {
72
+ const bare = toWireInstance(instance({ agentInstanceKey: "ai-1", elementId: "", creationDate: "" }));
73
+ assert.deepEqual(bare, { agentInstanceKey: "ai-1", status: "COMPLETED", processInstanceKey: "pi-1" });
74
+
75
+ const full = toWireInstance(
76
+ instance({
77
+ agentInstanceKey: "ai-2",
78
+ elementId: "implement-task",
79
+ elementInstanceKeys: ["ei-9"],
80
+ rootProcessInstanceKey: "root-1",
81
+ metrics: { inputTokens: 10, outputTokens: 4, modelCalls: 2, toolCalls: 1 },
82
+ creationDate: "2024-01-01T00:00:00Z",
83
+ completionDate: "2024-01-01T00:05:00Z",
84
+ }),
85
+ );
86
+ assert.equal(full.elementId, "implement-task");
87
+ assert.deepEqual(full.elementInstanceKeys, ["ei-9"]);
88
+ assert.deepEqual(full.metrics, { inputTokens: 10, outputTokens: 4, modelCalls: 2, toolCalls: 1 });
89
+ assert.equal(full.completionDate, "2024-01-01T00:05:00Z");
90
+ });
91
+
92
+ test("toWireRecord preserves conversation grammar and per-turn metrics", () => {
93
+ const wire = toWireRecord(
94
+ record({
95
+ historyItemKey: "h-1",
96
+ loopIteration: 3,
97
+ role: "ASSISTANT",
98
+ content: [
99
+ { contentType: "TEXT", text: "hello" },
100
+ { contentType: "OBJECT", object: { a: 1 } },
101
+ ],
102
+ toolCalls: [{ toolCallId: "t-1", toolName: "grep", elementId: "tool-task", arguments: { q: "x" } }],
103
+ metrics: {
104
+ inputTokens: 5,
105
+ outputTokens: 2,
106
+ reasoningTokenCount: 1,
107
+ cacheCreationTokenCount: 0,
108
+ cacheReadTokenCount: 0,
109
+ durationMs: 1200,
110
+ },
111
+ elementInstanceKey: "ei-3",
112
+ }),
113
+ );
114
+ assert.equal(wire.content.length, 2);
115
+ assert.equal(wire.content[0]?.text, "hello");
116
+ assert.deepEqual(wire.content[1]?.object, { a: 1 });
117
+ assert.equal(wire.toolCalls[0]?.elementId, "tool-task");
118
+ assert.deepEqual(wire.toolCalls[0]?.arguments, { q: "x" });
119
+ assert.equal(wire.metrics?.durationMs, 1200);
120
+ assert.equal(wire.elementInstanceKey, "ei-3");
121
+ });
122
+
123
+ test("listAgentInstances applies only non-blank selectors and sorts newest-created first", async () => {
124
+ const reader = new FakeReader([
125
+ instance({ agentInstanceKey: "old", creationDate: "2024-01-01T00:00:00Z" }),
126
+ instance({ agentInstanceKey: "new", creationDate: "2024-02-01T00:00:00Z" }),
127
+ ]);
128
+ const out = await listAgentInstances(reader, { processInstanceKey: "pi-1", status: "", elementId: undefined });
129
+ assert.deepEqual(reader.instanceFilter, { processInstanceKey: "pi-1" });
130
+ assert.equal(out.count, 2);
131
+ assert.deepEqual(
132
+ out.instances.map((i) => i.agentInstanceKey),
133
+ ["new", "old"],
134
+ );
135
+ });
136
+
137
+ test("readAgentHistory sorts by loopIteration then key, enriches with the owning instance", async () => {
138
+ const owner = instance({ agentInstanceKey: "ai-1", metrics: { inputTokens: 1, outputTokens: 1, modelCalls: 1, toolCalls: 0 } });
139
+ const reader = new FakeReader(
140
+ [],
141
+ [
142
+ record({ historyItemKey: "h-2", loopIteration: 1 }),
143
+ record({ historyItemKey: "h-1", loopIteration: 1 }),
144
+ record({ historyItemKey: "h-0", loopIteration: 0 }),
145
+ ],
146
+ { "ai-1": owner },
147
+ );
148
+ const out = await readAgentHistory(reader, "ai-1", { role: "ASSISTANT", loopIteration: 1, elementInstanceKey: "" });
149
+ assert.equal(reader.historyKey, "ai-1");
150
+ assert.deepEqual(reader.historyFilter, { role: "ASSISTANT", loopIteration: 1 });
151
+ assert.deepEqual(
152
+ out.records.map((r) => r.historyItemKey),
153
+ ["h-0", "h-1", "h-2"],
154
+ );
155
+ assert.equal(out.instance?.agentInstanceKey, "ai-1");
156
+ assert.equal(out.instance?.metrics?.inputTokens, 1);
157
+ });
158
+
159
+ test("readAgentHistory read-as-absence: a blank key yields an empty history and no engine call", async () => {
160
+ const reader = new FakeReader([instance({ agentInstanceKey: "x" })], [record({ historyItemKey: "h" })]);
161
+ const out = await readAgentHistory(reader, "");
162
+ assert.equal(out.count, 0);
163
+ assert.equal(out.agentInstanceKey, "");
164
+ assert.equal(reader.historyKey, undefined);
165
+ });
@@ -0,0 +1,211 @@
1
+ // nano-workforce — the engine-native AgentInstance/AgentHistory READ path (issue #745 / #747,
2
+ // umbrella #746). The CONSUMER half of the durable-agent-transcript work.
3
+ //
4
+ // The write path is engine-native: the worker harness (jwulf/c8ctl-plugin-nano#194) mints
5
+ // Create/Update/Complete AgentInstance/AgentHistory records against the engine for every element that
6
+ // carries the `<zeebe:agentDefinition agentType="external"/>` marker (the PRODUCER half, landed in
7
+ // #748). This module is the READ counterpart: it projects the engine's durable AgentInstance +
8
+ // AgentHistory read model onto the wire shapes the Cockpit "historical" transcript + per-turn metrics
9
+ // view renders — keyed by AGENT-INSTANCE / PROCESS-INSTANCE / ELEMENT-INSTANCE keys, never the
10
+ // slash-bearing `job:<jobKey>` relay stream id (so the #744 gateway-proxy bug class is moot for
11
+ // settled history; live tail stays on the relay overlay).
12
+ //
13
+ // The engine reach is the SINGLE engine-read seam — `@nanobpm/urban`'s `EngineClient`
14
+ // (`searchAgentInstances` / `searchAgentInstanceHistory` / `getAgentInstance`, added in urban 0.93 /
15
+ // nanobpm/nano-ide#563). No second broker-REST client, no `orchestration-cluster-api-js` fork: option
16
+ // (a) from the escalation, so the read path is exercised by the testkit WASM double
17
+ // (`@nanobpm/urban-testkit` ≥ 1.4 records none — read-as-absence — while a live engine validates the
18
+ // behavioural parity).
19
+ //
20
+ // Invariant fit (ADR 0056): this is an ADVISORY, READ-ONLY engine query. It observes the engine read
21
+ // model to render a visibility view; it NEVER activates/completes a job, publishes a message, or gates
22
+ // a BPMN sequence flow. It is deliberately expressed against a NARROW reader shape (not the whole
23
+ // `EngineClient`) so the callers that drive it stay structurally decoupled from the engine — the same
24
+ // discipline as `./element-instance.ts`.
25
+ //
26
+ // Pure and side-effect-free apart from the injected reader: unit-testable on Node with a fake reader.
27
+
28
+ import type {
29
+ AgentHistoryFilter,
30
+ AgentHistoryRecord,
31
+ AgentInstanceFilter,
32
+ AgentInstanceSummary,
33
+ } from "@nanobpm/urban";
34
+ import type {
35
+ AgentHistory as WireAgentHistory,
36
+ AgentHistoryRecord as WireAgentHistoryRecord,
37
+ AgentInstance as WireAgentInstance,
38
+ AgentInstanceList as WireAgentInstanceList,
39
+ } from "../../nano-generated/api-io.d.ts";
40
+
41
+ /**
42
+ * The narrow slice of the engine read model the historical-transcript consumer needs: the three
43
+ * engine-native agent read methods. `@nanobpm/urban`'s `EngineClient` satisfies it structurally; a
44
+ * test supplies a fake. Kept minimal (three methods, not the whole `EngineClient`) so a caller depends
45
+ * on a capability, not the engine.
46
+ */
47
+ export interface AgentHistoryReader {
48
+ searchAgentInstances(filter?: AgentInstanceFilter): Promise<readonly AgentInstanceSummary[]>;
49
+ searchAgentInstanceHistory(
50
+ agentInstanceKey: string,
51
+ filter?: AgentHistoryFilter,
52
+ ): Promise<readonly AgentHistoryRecord[]>;
53
+ getAgentInstance(agentInstanceKey: string): Promise<AgentInstanceSummary | null>;
54
+ }
55
+
56
+ /** The selectors {@link listAgentInstances} understands (all optional; an empty filter lists all). */
57
+ export interface AgentInstanceQuery {
58
+ readonly processInstanceKey?: string;
59
+ readonly rootProcessInstanceKey?: string;
60
+ readonly status?: string;
61
+ readonly elementId?: string;
62
+ }
63
+
64
+ /** The selectors {@link readAgentHistory} understands beyond the required `agentInstanceKey`. */
65
+ export interface AgentHistoryQuery {
66
+ readonly role?: AgentHistoryRecord["role"];
67
+ readonly loopIteration?: number;
68
+ readonly elementInstanceKey?: string;
69
+ }
70
+
71
+ /** Drop an empty/blank string filter value (No Drift Surfaces — the presence rule the key selectors
72
+ * elsewhere in this seam use: an omitted/blank selector is not applied). */
73
+ function present(value: string | undefined): value is string {
74
+ return value !== undefined && value !== "";
75
+ }
76
+
77
+ /** Project an engine {@link AgentInstanceSummary} onto the wire {@link WireAgentInstance}, dropping the
78
+ * optional fields the engine did not report (so the wire object is minimal and stable). */
79
+ export function toWireInstance(summary: AgentInstanceSummary): WireAgentInstance {
80
+ const out: WireAgentInstance = {
81
+ agentInstanceKey: summary.agentInstanceKey,
82
+ status: summary.status,
83
+ processInstanceKey: summary.processInstanceKey,
84
+ };
85
+ if (present(summary.elementId)) out.elementId = summary.elementId;
86
+ if (summary.elementInstanceKeys !== undefined && summary.elementInstanceKeys.length > 0) {
87
+ out.elementInstanceKeys = [...summary.elementInstanceKeys];
88
+ }
89
+ if (present(summary.rootProcessInstanceKey)) out.rootProcessInstanceKey = summary.rootProcessInstanceKey;
90
+ if (present(summary.processDefinitionKey)) out.processDefinitionKey = summary.processDefinitionKey;
91
+ if (present(summary.processDefinitionId)) out.processDefinitionId = summary.processDefinitionId;
92
+ if (summary.metrics !== undefined) {
93
+ out.metrics = {
94
+ inputTokens: summary.metrics.inputTokens,
95
+ outputTokens: summary.metrics.outputTokens,
96
+ modelCalls: summary.metrics.modelCalls,
97
+ toolCalls: summary.metrics.toolCalls,
98
+ };
99
+ }
100
+ if (present(summary.creationDate)) out.creationDate = summary.creationDate;
101
+ if (present(summary.lastUpdatedDate)) out.lastUpdatedDate = summary.lastUpdatedDate;
102
+ if (present(summary.completionDate)) out.completionDate = summary.completionDate;
103
+ return out;
104
+ }
105
+
106
+ /** Project one engine {@link AgentHistoryRecord} (turn) onto the wire {@link WireAgentHistoryRecord},
107
+ * preserving the Camunda `AgentHistoryRecordValue` conversation grammar (role, content blocks, tool
108
+ * calls, per-turn metrics) the transcript store already models — one shape, no drift. */
109
+ export function toWireRecord(record: AgentHistoryRecord): WireAgentHistoryRecord {
110
+ const out: WireAgentHistoryRecord = {
111
+ historyItemKey: record.historyItemKey,
112
+ agentInstanceKey: record.agentInstanceKey,
113
+ loopIteration: record.loopIteration,
114
+ role: record.role,
115
+ commitStatus: record.commitStatus,
116
+ content: record.content.map((block) => {
117
+ const b: WireAgentHistoryRecord["content"][number] = { contentType: block.contentType };
118
+ if (block.text !== undefined) b.text = block.text;
119
+ if (block.documentReference !== undefined) b.documentReference = block.documentReference;
120
+ if (block.object !== undefined) b.object = block.object;
121
+ return b;
122
+ }),
123
+ toolCalls: record.toolCalls.map((call) => {
124
+ const c: WireAgentHistoryRecord["toolCalls"][number] = {
125
+ toolCallId: call.toolCallId,
126
+ toolName: call.toolName,
127
+ arguments: { ...call.arguments },
128
+ };
129
+ if (present(call.elementId)) c.elementId = call.elementId;
130
+ return c;
131
+ }),
132
+ };
133
+ if (record.metrics !== undefined) {
134
+ out.metrics = {
135
+ inputTokens: record.metrics.inputTokens,
136
+ outputTokens: record.metrics.outputTokens,
137
+ reasoningTokenCount: record.metrics.reasoningTokenCount,
138
+ cacheCreationTokenCount: record.metrics.cacheCreationTokenCount,
139
+ cacheReadTokenCount: record.metrics.cacheReadTokenCount,
140
+ durationMs: record.metrics.durationMs,
141
+ };
142
+ }
143
+ if (present(record.elementInstanceKey)) out.elementInstanceKey = record.elementInstanceKey;
144
+ if (present(record.jobKey)) out.jobKey = record.jobKey;
145
+ if (present(record.producedAt)) out.producedAt = record.producedAt;
146
+ return out;
147
+ }
148
+
149
+ /**
150
+ * List the engine-native agent instances matching `query`, projected onto the wire list shape and
151
+ * sorted newest-created-first (stable on `agentInstanceKey`). Only non-blank selectors are applied.
152
+ * Read-as-absence: an engine with no AgentInstance channel (or no matching instance) yields an empty
153
+ * list, never an error.
154
+ */
155
+ export async function listAgentInstances(
156
+ reader: AgentHistoryReader,
157
+ query: AgentInstanceQuery = {},
158
+ ): Promise<WireAgentInstanceList> {
159
+ const filter: AgentInstanceFilter = {
160
+ ...(present(query.processInstanceKey) ? { processInstanceKey: query.processInstanceKey } : {}),
161
+ ...(present(query.rootProcessInstanceKey) ? { rootProcessInstanceKey: query.rootProcessInstanceKey } : {}),
162
+ ...(present(query.status) ? { status: query.status } : {}),
163
+ ...(present(query.elementId) ? { elementId: query.elementId } : {}),
164
+ };
165
+ const summaries = await reader.searchAgentInstances(filter);
166
+ const instances = summaries.map(toWireInstance).sort((a, b) => {
167
+ // Newest-created first; a missing creationDate sorts last (oldest), stable on the key.
168
+ const byTime = (b.creationDate ?? "").localeCompare(a.creationDate ?? "");
169
+ return byTime !== 0 ? byTime : a.agentInstanceKey.localeCompare(b.agentInstanceKey);
170
+ });
171
+ return { count: instances.length, generatedAt: new Date().toISOString(), instances };
172
+ }
173
+
174
+ /**
175
+ * Read one agent instance's durable conversation history (turns + per-turn metrics) from the engine,
176
+ * projected onto the wire shape and sorted in conversational order — by `loopIteration`, then by the
177
+ * creation-ordered `historyItemKey` within an iteration. Enriched with the owning instance's summary
178
+ * (its rolled-up metrics + lifecycle) when the engine still reports it. A blank key or an unknown
179
+ * instance yields an empty history (read-as-absence), never an error.
180
+ */
181
+ export async function readAgentHistory(
182
+ reader: AgentHistoryReader,
183
+ agentInstanceKey: string,
184
+ query: AgentHistoryQuery = {},
185
+ ): Promise<WireAgentHistory> {
186
+ if (!present(agentInstanceKey)) {
187
+ return { agentInstanceKey: "", count: 0, generatedAt: new Date().toISOString(), records: [] };
188
+ }
189
+ const filter: AgentHistoryFilter = {
190
+ ...(query.role !== undefined ? { role: query.role } : {}),
191
+ ...(query.loopIteration !== undefined ? { loopIteration: query.loopIteration } : {}),
192
+ ...(present(query.elementInstanceKey) ? { elementInstanceKey: query.elementInstanceKey } : {}),
193
+ };
194
+
195
+ const [rawRecords, summary] = await Promise.all([
196
+ reader.searchAgentInstanceHistory(agentInstanceKey, filter),
197
+ reader.getAgentInstance(agentInstanceKey),
198
+ ]);
199
+ const records = rawRecords.map(toWireRecord).sort((a, b) => {
200
+ if (a.loopIteration !== b.loopIteration) return a.loopIteration - b.loopIteration;
201
+ return a.historyItemKey.localeCompare(b.historyItemKey);
202
+ });
203
+ const out: WireAgentHistory = {
204
+ agentInstanceKey,
205
+ count: records.length,
206
+ generatedAt: new Date().toISOString(),
207
+ records,
208
+ };
209
+ if (summary !== null) out.instance = toWireInstance(summary);
210
+ return out;
211
+ }
@@ -0,0 +1,85 @@
1
+ // DOM-render tests for the cockpit engine-native agent-history renderer (issue #745/#747). Exercises
2
+ // the pure renderer against the in-memory FakeDocument/FakeElement doubles — no browser.
3
+ import assert from "node:assert/strict";
4
+ import { test } from "node:test";
5
+ import { FakeDocument, FakeElement } from "../../../test/agentic-cockpit-doubles.ts";
6
+ import type { AgentHistoryView, AgentSessionsView } from "./agent-history-view.ts";
7
+ import { renderAgentHistory, renderAgentSessions } from "./agent-history-render.ts";
8
+
9
+ const doc = new FakeDocument();
10
+
11
+ test("renderAgentSessions lists runs and wires onSelect to the instance key", () => {
12
+ const host = new FakeElement("div");
13
+ const view: AgentSessionsView = {
14
+ count: 1,
15
+ sessions: [
16
+ {
17
+ agentInstanceKey: "ai-1",
18
+ label: "feature \u00b7 implement-task",
19
+ status: "COMPLETED",
20
+ processInstanceKey: "pi-1",
21
+ metrics: "1.5k in \u00b7 340 out \u00b7 3 calls \u00b7 5 tools",
22
+ capturedAt: "2024-01-01T00:00:00Z",
23
+ },
24
+ ],
25
+ };
26
+ const selected: string[] = [];
27
+ renderAgentSessions(host, doc, view, { onSelect: (k) => selected.push(k), activeInstanceKey: "ai-1" });
28
+
29
+ const rows = host.byClass("cockpit-agent-session");
30
+ assert.equal(rows.length, 1);
31
+ assert.equal(rows[0]?.getAttribute("data-agent-instance-key"), "ai-1");
32
+ assert.equal(rows[0]?.getAttribute("data-active"), "true");
33
+ const button = host.byClass("cockpit-agent-select")[0];
34
+ assert.ok(button);
35
+ button?.dispatch("click");
36
+ assert.deepEqual(selected, ["ai-1"]);
37
+ });
38
+
39
+ test("renderAgentSessions renders an explicit empty state (read-as-absence)", () => {
40
+ const host = new FakeElement("div");
41
+ renderAgentSessions(host, doc, { count: 0, sessions: [] });
42
+ assert.equal(host.byData("empty", "true").length, 1);
43
+ assert.equal(host.byClass("cockpit-agent-session").length, 0);
44
+ });
45
+
46
+ test("renderAgentHistory renders turns with role, text, tool calls and per-turn metrics", () => {
47
+ const host = new FakeElement("div");
48
+ const view: AgentHistoryView = {
49
+ agentInstanceKey: "ai-1",
50
+ count: 1,
51
+ instance: {
52
+ agentInstanceKey: "ai-1",
53
+ label: "feature",
54
+ status: "COMPLETED",
55
+ processInstanceKey: "pi-1",
56
+ metrics: "20 in \u00b7 8 out \u00b7 2 calls \u00b7 1 tools",
57
+ },
58
+ turns: [
59
+ {
60
+ historyItemKey: "h-1",
61
+ loopIteration: 1,
62
+ role: "ASSISTANT",
63
+ text: "on it",
64
+ toolCalls: [{ toolCallId: "t-1", toolName: "grep", elementId: "tool" }],
65
+ metrics: "12 in \u00b7 4 out \u00b7 900ms",
66
+ },
67
+ ],
68
+ };
69
+ renderAgentHistory(host, doc, view);
70
+
71
+ const root = host.byClass("cockpit-agent-transcript")[0];
72
+ assert.equal(root?.getAttribute("data-agent-instance-key"), "ai-1");
73
+ assert.equal(host.byData("summary", "agent-instance-metrics").length, 1);
74
+ const turn = host.byClass("cockpit-agent-turn")[0];
75
+ assert.equal(turn?.getAttribute("data-role"), "ASSISTANT");
76
+ assert.equal(host.byClass("cockpit-agent-turn-text")[0]?.text(), "on it");
77
+ assert.equal(host.byClass("cockpit-agent-turn-tool")[0]?.text(), "grep (tool)");
78
+ assert.equal(host.byClass("cockpit-agent-turn-metrics")[0]?.text(), "12 in \u00b7 4 out \u00b7 900ms");
79
+ });
80
+
81
+ test("renderAgentHistory renders an empty history state", () => {
82
+ const host = new FakeElement("div");
83
+ renderAgentHistory(host, doc, { agentInstanceKey: "ai-9", count: 0, turns: [] });
84
+ assert.equal(host.byData("empty", "true").length, 1);
85
+ });
@@ -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
+ }