@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.
- package/CHANGELOG.md +12 -0
- package/app/agentic/agent-history.test.ts +165 -0
- package/app/agentic/agent-history.ts +211 -0
- package/app/agentic/claim-registry.test.ts +3 -2
- package/app/agentic/claim-registry.ts +8 -6
- package/app/agentic/cockpit/agent-history-render.test.ts +85 -0
- package/app/agentic/cockpit/agent-history-render.ts +168 -0
- package/app/agentic/cockpit/agent-history-view.test.ts +104 -0
- package/app/agentic/cockpit/agent-history-view.ts +186 -0
- package/app/agentic/cockpit/index.ts +21 -0
- package/app/agentic/cockpit/mount.test.ts +133 -1
- package/app/agentic/cockpit/supply-boot-agent-history.test.ts +144 -0
- package/app/agentic/cockpit/supply-boot.ts +134 -1
- package/app/agentic/cockpit/supply-view.ts +3 -3
- package/app/agentic/cockpit/transcript-view.ts +1 -1
- package/app/agentic/correlation-store.test.ts +14 -10
- package/app/agentic/correlation-store.ts +4 -3
- package/app/agentic/correlation.test.ts +24 -16
- package/app/agentic/correlation.ts +25 -12
- package/app/agentic/families/claim.family.test.ts +2 -1
- package/app/agentic/families/relay.family.test.ts +74 -73
- package/app/agentic/families/relay.family.ts +24 -21
- package/app/agentic/transcript-read.test.ts +108 -17
- package/app/agentic/transcript-read.ts +41 -7
- package/app/contracts.ts +10 -2
- package/app/mcpToolSurface.ts +8 -1
- package/db/migrations/101_agentic_history_read_expand.sql +34 -0
- package/openapi.yaml +360 -0
- package/operations/agentHistoryEndpoints.test.ts +106 -0
- package/operations/getAgentInstanceHistory.ts +37 -0
- package/operations/getAgenticSupply.test.ts +41 -2
- package/operations/getAgenticSupply.ts +7 -5
- package/operations/getAgenticTranscript.test.ts +5 -4
- package/operations/listAgentInstances.ts +42 -0
- package/operations/listAgenticTranscripts.test.ts +10 -9
- package/package.json +3 -3
- package/pages/cockpit/cockpit.css +104 -0
- package/pages/cockpit/mount.js +286 -3
- package/test/agentic-e2e.test.ts +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
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
|
+
|
|
7
|
+
## [0.183.2](https://github.com/nanobpm/nano-workforce/compare/v0.183.1...v0.183.2) (2026-09-07)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **cockpit:** read transcripts on instance-scoped composeStreamId stream, not job:<jobKey> ([#754](https://github.com/nanobpm/nano-workforce/issues/754)) ([03fabb5](https://github.com/nanobpm/nano-workforce/commit/03fabb57c79c25ac0334bc0bd4a672348e5e507c)), closes [c8ctl-plugin-nano#186](https://github.com/nanobpm/c8ctl-plugin-nano/issues/186) [nano-ide#557](https://github.com/nanobpm/nano-ide/issues/557) [#543](https://github.com/nanobpm/nano-workforce/issues/543) [#738](https://github.com/nanobpm/nano-workforce/issues/738) [#738](https://github.com/nanobpm/nano-workforce/issues/738) [#738](https://github.com/nanobpm/nano-workforce/issues/738) [#543](https://github.com/nanobpm/nano-workforce/issues/543)
|
|
12
|
+
|
|
1
13
|
## [0.183.1](https://github.com/nanobpm/nano-workforce/compare/v0.183.0...v0.183.1) (2026-09-07)
|
|
2
14
|
|
|
3
15
|
### 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
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import assert from "node:assert/strict";
|
|
10
10
|
import { test } from "node:test";
|
|
11
11
|
|
|
12
|
+
import { composeStreamId } from "@nanobpm/agentic/emit";
|
|
12
13
|
import {
|
|
13
14
|
ClaimRegistry,
|
|
14
15
|
currentClaimRegistry,
|
|
@@ -22,7 +23,7 @@ test("claim records both projections; jobKeysFor and primaryStreamFor resolve ag
|
|
|
22
23
|
assert.deepEqual(reg.jobKeysFor("wk-a"), ["8420"]);
|
|
23
24
|
assert.equal(reg.ownerOf("8420"), "wk-a");
|
|
24
25
|
assert.equal(reg.isClaimed("8420"), true);
|
|
25
|
-
assert.equal(reg.primaryStreamFor("wk-a"), "
|
|
26
|
+
assert.equal(reg.primaryStreamFor("wk-a"), composeStreamId("wk-a", "8420"));
|
|
26
27
|
assert.equal(reg.count(), 1);
|
|
27
28
|
});
|
|
28
29
|
|
|
@@ -62,7 +63,7 @@ test("a worker can hold several claims; jobKeysFor is sorted and primaryStreamFo
|
|
|
62
63
|
reg.claim("wk-a", "8419");
|
|
63
64
|
reg.claim("wk-a", "8421");
|
|
64
65
|
assert.deepEqual(reg.jobKeysFor("wk-a"), ["8419", "8420", "8421"]);
|
|
65
|
-
assert.equal(reg.primaryStreamFor("wk-a"), "
|
|
66
|
+
assert.equal(reg.primaryStreamFor("wk-a"), composeStreamId("wk-a", "8419"));
|
|
66
67
|
});
|
|
67
68
|
|
|
68
69
|
test("release clears one claim; a late / duplicate release is a no-op", () => {
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// type on the job protocol; ADVISORY — the registry is a read-only visibility source and NEVER
|
|
26
26
|
// hard-locks or gates a BPMN sequence flow.
|
|
27
27
|
|
|
28
|
-
import {
|
|
28
|
+
import { composeStreamId } from "@nanobpm/agentic/emit";
|
|
29
29
|
|
|
30
30
|
/** One ownership record: a worker instance's claim over a job. */
|
|
31
31
|
export interface Claim {
|
|
@@ -138,14 +138,16 @@ export class ClaimRegistry {
|
|
|
138
138
|
|
|
139
139
|
/**
|
|
140
140
|
* The jobKey-scoped relay stream a worker's terminal should drill into: its lowest-sorted current
|
|
141
|
-
* claim's stream (`
|
|
142
|
-
* it stable if it ever holds several. Undefined when the worker holds no
|
|
143
|
-
* falls back to the instance-keyed stream. This is the "relay demoted to
|
|
144
|
-
* CLAIM (not by `instanceForConnection`)" seam.
|
|
141
|
+
* claim's stream (`composeStreamId(instance, jobKey)`). A worker runs one job at a time in this
|
|
142
|
+
* fleet, but sorting keeps it stable if it ever holds several. Undefined when the worker holds no
|
|
143
|
+
* claim — the caller then falls back to the instance-keyed stream. This is the "relay demoted to
|
|
144
|
+
* drill-in, keyed by the CLAIM (not by `instanceForConnection`)" seam. The instance-scoped stream id
|
|
145
|
+
* is the one canonical `@nanobpm/agentic/emit` codec the producer writes under (issue #738), so the
|
|
146
|
+
* cockpit drills the SAME stream the transcript bytes actually land on.
|
|
145
147
|
*/
|
|
146
148
|
primaryStreamFor(instance: string): string | undefined {
|
|
147
149
|
const [first] = this.jobKeysFor(instance);
|
|
148
|
-
return first === undefined ? undefined :
|
|
150
|
+
return first === undefined ? undefined : composeStreamId(instance, first);
|
|
149
151
|
}
|
|
150
152
|
|
|
151
153
|
/** Whether any instance currently owns `jobKey`. */
|
|
@@ -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
|
+
});
|