@nanobpm/agentic 0.1.0 → 0.5.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 (128) hide show
  1. package/README.md +2 -1
  2. package/dist/demand/model.d.ts +7 -4
  3. package/dist/demand/model.js +22 -4
  4. package/dist/demand/taskdef.d.ts +13 -1
  5. package/dist/demand/taskdef.js +20 -2
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +1 -0
  8. package/dist/protocol/conformance/frames.js +32 -4
  9. package/dist/protocol/index.d.ts +1 -1
  10. package/dist/protocol/payloads.d.ts +44 -0
  11. package/dist/protocol/payloads.js +61 -7
  12. package/dist/session/acp/client.d.ts +109 -0
  13. package/dist/session/acp/client.js +254 -0
  14. package/dist/session/acp/index.d.ts +27 -0
  15. package/dist/session/acp/index.js +27 -0
  16. package/dist/session/acp/jsonrpc.d.ts +25 -0
  17. package/dist/session/acp/jsonrpc.js +148 -0
  18. package/dist/session/acp/normalize.d.ts +48 -0
  19. package/dist/session/acp/normalize.js +162 -0
  20. package/dist/session/acp/protocol.d.ts +94 -0
  21. package/dist/session/acp/protocol.js +136 -0
  22. package/dist/session/acp/spawn.d.ts +36 -0
  23. package/dist/session/acp/spawn.js +68 -0
  24. package/dist/session/acp/transport.d.ts +62 -0
  25. package/dist/session/acp/transport.js +126 -0
  26. package/dist/session/adapter.d.ts +135 -0
  27. package/dist/session/adapter.js +24 -0
  28. package/dist/session/backend.d.ts +43 -0
  29. package/dist/session/backend.js +95 -0
  30. package/dist/session/events.d.ts +152 -0
  31. package/dist/session/events.js +192 -0
  32. package/dist/session/index.d.ts +31 -0
  33. package/dist/session/index.js +5 -0
  34. package/dist/session/log.d.ts +107 -0
  35. package/dist/session/log.js +351 -0
  36. package/dist/session/normalizer/claude.d.ts +23 -0
  37. package/dist/session/normalizer/claude.js +138 -0
  38. package/dist/session/normalizer/copilot.d.ts +27 -0
  39. package/dist/session/normalizer/copilot.js +105 -0
  40. package/dist/session/normalizer/deepseek.d.ts +11 -0
  41. package/dist/session/normalizer/deepseek.js +68 -0
  42. package/dist/session/normalizer/index.d.ts +36 -0
  43. package/dist/session/normalizer/index.js +29 -0
  44. package/dist/session/normalizer/kimi.d.ts +10 -0
  45. package/dist/session/normalizer/kimi.js +80 -0
  46. package/dist/session/normalizer/link.d.ts +36 -0
  47. package/dist/session/normalizer/link.js +56 -0
  48. package/dist/session/normalizer/pi.d.ts +13 -0
  49. package/dist/session/normalizer/pi.js +61 -0
  50. package/dist/session/normalizer/qwen.d.ts +11 -0
  51. package/dist/session/normalizer/qwen.js +65 -0
  52. package/dist/session/normalizer/record.d.ts +21 -0
  53. package/dist/session/normalizer/record.js +87 -0
  54. package/dist/session/normalizer/types.d.ts +139 -0
  55. package/dist/session/normalizer/types.js +31 -0
  56. package/dist/session/schema.d.ts +38 -0
  57. package/dist/session/schema.js +74 -0
  58. package/dist/transcript/index.d.ts +2 -2
  59. package/dist/transcript/index.js +1 -1
  60. package/dist/transcript/schema.d.ts +23 -1
  61. package/dist/transcript/schema.js +34 -1
  62. package/dist/transcript/store.d.ts +93 -5
  63. package/dist/transcript/store.js +287 -6
  64. package/package.json +17 -1
  65. package/src/demand/model.test.ts +82 -4
  66. package/src/demand/model.ts +30 -9
  67. package/src/demand/taskdef.test.ts +51 -6
  68. package/src/demand/taskdef.ts +31 -2
  69. package/src/index.ts +1 -0
  70. package/src/protocol/conformance/frames.ts +32 -4
  71. package/src/protocol/index.ts +4 -0
  72. package/src/protocol/payloads.test.ts +31 -1
  73. package/src/protocol/payloads.ts +110 -7
  74. package/src/session/acp/client.test.ts +222 -0
  75. package/src/session/acp/client.ts +356 -0
  76. package/src/session/acp/fake-agent.ts +71 -0
  77. package/src/session/acp/index.ts +68 -0
  78. package/src/session/acp/integration.test.ts +37 -0
  79. package/src/session/acp/jsonrpc.test.ts +75 -0
  80. package/src/session/acp/jsonrpc.ts +171 -0
  81. package/src/session/acp/normalize.test.ts +150 -0
  82. package/src/session/acp/normalize.ts +204 -0
  83. package/src/session/acp/protocol.ts +178 -0
  84. package/src/session/acp/spawn.test.ts +45 -0
  85. package/src/session/acp/spawn.ts +91 -0
  86. package/src/session/acp/transport.test.ts +82 -0
  87. package/src/session/acp/transport.ts +155 -0
  88. package/src/session/adapter.ts +159 -0
  89. package/src/session/backend.test.ts +198 -0
  90. package/src/session/backend.ts +128 -0
  91. package/src/session/events.test.ts +168 -0
  92. package/src/session/events.ts +347 -0
  93. package/src/session/index.ts +67 -0
  94. package/src/session/log.test.ts +215 -0
  95. package/src/session/log.ts +525 -0
  96. package/src/session/normalizer/backend-integration.test.ts +103 -0
  97. package/src/session/normalizer/claude.test.ts +68 -0
  98. package/src/session/normalizer/claude.ts +136 -0
  99. package/src/session/normalizer/copilot.test.ts +59 -0
  100. package/src/session/normalizer/copilot.ts +133 -0
  101. package/src/session/normalizer/deepseek.ts +80 -0
  102. package/src/session/normalizer/index.ts +61 -0
  103. package/src/session/normalizer/kimi.ts +82 -0
  104. package/src/session/normalizer/link.test.ts +24 -0
  105. package/src/session/normalizer/link.ts +81 -0
  106. package/src/session/normalizer/pi.ts +75 -0
  107. package/src/session/normalizer/probe.test.ts +49 -0
  108. package/src/session/normalizer/qwen.test.ts +20 -0
  109. package/src/session/normalizer/qwen.ts +77 -0
  110. package/src/session/normalizer/record.test.ts +68 -0
  111. package/src/session/normalizer/record.ts +88 -0
  112. package/src/session/normalizer/resume.test.ts +25 -0
  113. package/src/session/normalizer/types.ts +152 -0
  114. package/src/session/normalizer/vectors.test.ts +180 -0
  115. package/src/session/schema.test.ts +84 -0
  116. package/src/session/schema.ts +78 -0
  117. package/src/session/test-db.ts +56 -0
  118. package/src/transcript/index.ts +8 -0
  119. package/src/transcript/schema.test.ts +31 -4
  120. package/src/transcript/schema.ts +36 -1
  121. package/src/transcript/store.ts +438 -6
  122. package/src/transcript/turns.test.ts +334 -0
  123. package/dist/blackboard/test-db.d.ts +0 -5
  124. package/dist/blackboard/test-db.js +0 -42
  125. package/dist/presence/test-db.d.ts +0 -5
  126. package/dist/presence/test-db.js +0 -42
  127. package/dist/transcript/test-db.d.ts +0 -5
  128. package/dist/transcript/test-db.js +0 -41
@@ -0,0 +1,171 @@
1
+ /**
2
+ * A minimal JSON-RPC 2.0 peer for ACP — ADR 0062, slice 2.
3
+ *
4
+ * ACP is symmetric: the client sends requests/notifications to the agent
5
+ * (`initialize`, `session/*`) and the agent sends requests/notifications back to
6
+ * the client (`session/update`, `session/request_permission`). {@link AcpConnection}
7
+ * is the bidirectional peer over an {@link AcpTransport}: it correlates responses
8
+ * to outbound requests by id, dispatches inbound notifications and requests to
9
+ * registered handlers, and answers an unhandled inbound request with a
10
+ * JSON-RPC "method not found" instead of hanging the agent.
11
+ *
12
+ * It is intentionally tiny and dependency-free — the repo has no JSON-RPC library
13
+ * and this is the only surface that needs one, so a focused peer beats pulling a
14
+ * general framework (and avoids a drift surface).
15
+ */
16
+ import { isRecord } from "./protocol.ts";
17
+ import type { AcpTransport } from "./transport.ts";
18
+
19
+ /** A handler for an inbound agent→client request; its resolved value is the result. */
20
+ export type AcpRequestHandler = (params: unknown) => unknown | Promise<unknown>;
21
+
22
+ /** A handler for an inbound agent→client notification (no response). */
23
+ export type AcpNotificationHandler = (params: unknown) => void;
24
+
25
+ /** A JSON-RPC error returned by, or raised toward, the peer. */
26
+ export class AcpRpcError extends Error {
27
+ readonly code: number;
28
+ readonly data: unknown;
29
+ constructor(code: number, message: string, data?: unknown) {
30
+ super(message);
31
+ this.name = "AcpRpcError";
32
+ this.code = code;
33
+ this.data = data;
34
+ }
35
+ }
36
+
37
+ const METHOD_NOT_FOUND = -32601;
38
+ const INTERNAL_ERROR = -32603;
39
+
40
+ interface Pending {
41
+ resolve: (value: unknown) => void;
42
+ reject: (reason: Error) => void;
43
+ }
44
+
45
+ export class AcpConnection {
46
+ readonly #transport: AcpTransport;
47
+ readonly #pending = new Map<number, Pending>();
48
+ readonly #notificationHandlers = new Map<string, AcpNotificationHandler>();
49
+ readonly #requestHandlers = new Map<string, AcpRequestHandler>();
50
+ #nextId = 1;
51
+ #closed = false;
52
+
53
+ constructor(transport: AcpTransport) {
54
+ this.#transport = transport;
55
+ transport.onMessage((message) => this.#dispatch(message));
56
+ transport.onError((error) => this.#failAll(error));
57
+ }
58
+
59
+ /** Send a request and resolve with its `result` (or reject with its `error`). */
60
+ request(method: string, params?: unknown): Promise<unknown> {
61
+ if (this.#closed) {
62
+ return Promise.reject(new Error(`ACP connection is closed; cannot call ${method}`));
63
+ }
64
+ const id = this.#nextId++;
65
+ return new Promise<unknown>((resolve, reject) => {
66
+ this.#pending.set(id, { resolve, reject });
67
+ this.#transport.send({ jsonrpc: "2.0", id, method, params });
68
+ });
69
+ }
70
+
71
+ /** Send a fire-and-forget notification (no id, no response). */
72
+ notify(method: string, params?: unknown): void {
73
+ if (this.#closed) return;
74
+ this.#transport.send({ jsonrpc: "2.0", method, params });
75
+ }
76
+
77
+ /** Register the handler for an inbound notification `method` (last wins). */
78
+ onNotification(method: string, handler: AcpNotificationHandler): void {
79
+ this.#notificationHandlers.set(method, handler);
80
+ }
81
+
82
+ /** Register the handler for an inbound request `method` (last wins). */
83
+ onRequest(method: string, handler: AcpRequestHandler): void {
84
+ this.#requestHandlers.set(method, handler);
85
+ }
86
+
87
+ /** Close the connection, rejecting every in-flight request. */
88
+ close(): void {
89
+ if (this.#closed) return;
90
+ this.#failAll(new Error("ACP connection closed"));
91
+ this.#closed = true;
92
+ this.#transport.close();
93
+ }
94
+
95
+ #dispatch(message: unknown): void {
96
+ if (!isRecord(message)) return;
97
+ const hasId = "id" in message && (typeof message.id === "number" || typeof message.id === "string");
98
+ const isResponse = hasId && ("result" in message || "error" in message);
99
+ if (isResponse) {
100
+ this.#handleResponse(message);
101
+ return;
102
+ }
103
+ if (typeof message.method === "string") {
104
+ if (hasId) {
105
+ this.#handleInboundRequest(message.method, message.id, message.params);
106
+ } else {
107
+ this.#handleInboundNotification(message.method, message.params);
108
+ }
109
+ }
110
+ }
111
+
112
+ #handleResponse(message: Record<string, unknown>): void {
113
+ const id = message.id;
114
+ if (typeof id !== "number") return; // we only ever issue numeric ids
115
+ const pending = this.#pending.get(id);
116
+ if (pending === undefined) return;
117
+ this.#pending.delete(id);
118
+ if ("error" in message && message.error !== undefined && message.error !== null) {
119
+ pending.reject(toRpcError(message.error));
120
+ return;
121
+ }
122
+ pending.resolve("result" in message ? message.result : undefined);
123
+ }
124
+
125
+ #handleInboundNotification(method: string, params: unknown): void {
126
+ const handler = this.#notificationHandlers.get(method);
127
+ if (handler !== undefined) handler(params);
128
+ }
129
+
130
+ #handleInboundRequest(method: string, id: unknown, params: unknown): void {
131
+ const handler = this.#requestHandlers.get(method);
132
+ if (handler === undefined) {
133
+ this.#transport.send({
134
+ jsonrpc: "2.0",
135
+ id,
136
+ error: { code: METHOD_NOT_FOUND, message: `method not found: ${method}` },
137
+ });
138
+ return;
139
+ }
140
+ Promise.resolve()
141
+ .then(() => handler(params))
142
+ .then(
143
+ (result) => this.#transport.send({ jsonrpc: "2.0", id, result: result ?? null }),
144
+ (reason: unknown) => this.#transport.send({ jsonrpc: "2.0", id, error: errorPayload(reason) }),
145
+ );
146
+ }
147
+
148
+ #failAll(error: Error): void {
149
+ for (const pending of this.#pending.values()) pending.reject(error);
150
+ this.#pending.clear();
151
+ }
152
+ }
153
+
154
+ function toRpcError(error: unknown): AcpRpcError {
155
+ if (isRecord(error)) {
156
+ const code = typeof error.code === "number" ? error.code : INTERNAL_ERROR;
157
+ const message = typeof error.message === "string" ? error.message : "ACP request failed";
158
+ return new AcpRpcError(code, message, error.data);
159
+ }
160
+ return new AcpRpcError(INTERNAL_ERROR, "ACP request failed with a non-object error");
161
+ }
162
+
163
+ function errorPayload(reason: unknown): { code: number; message: string; data?: unknown } {
164
+ if (reason instanceof AcpRpcError) {
165
+ return { code: reason.code, message: reason.message, data: reason.data };
166
+ }
167
+ if (reason instanceof Error) {
168
+ return { code: INTERNAL_ERROR, message: reason.message };
169
+ }
170
+ return { code: INTERNAL_ERROR, message: String(reason) };
171
+ }
@@ -0,0 +1,150 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { ACP_FIDELITY_GAPS, type AcpClassifiedUpdate, classifyUpdate } from "./normalize.ts";
4
+
5
+ function textChunk(sessionUpdate: string, text: string, extra: Record<string, unknown> = {}): unknown {
6
+ return { sessionUpdate, content: { type: "text", text }, ...extra };
7
+ }
8
+
9
+ test("agent_message_chunk → an assistant message", () => {
10
+ const result = classifyUpdate(textChunk("agent_message_chunk", "hello"));
11
+ assert.deepEqual(result, { kind: "message", role: "assistant", messageId: null, text: "hello" });
12
+ });
13
+
14
+ test("agent_thought_chunk → a reasoning message", () => {
15
+ const result = classifyUpdate(textChunk("agent_thought_chunk", "thinking", { messageId: "m1" }));
16
+ assert.deepEqual(result, { kind: "message", role: "reasoning", messageId: "m1", text: "thinking" });
17
+ });
18
+
19
+ test("user_message_chunk → a user message", () => {
20
+ const result = classifyUpdate(textChunk("user_message_chunk", "hi"));
21
+ assert.deepEqual(result, { kind: "message", role: "user", messageId: null, text: "hi" });
22
+ });
23
+
24
+ test("a chunk with an array of content blocks concatenates their text", () => {
25
+ const update = {
26
+ sessionUpdate: "agent_message_chunk",
27
+ content: [
28
+ { type: "text", text: "a" },
29
+ { type: "image", data: "…", mimeType: "image/png" },
30
+ { type: "text", text: "b" },
31
+ ],
32
+ };
33
+ assert.deepEqual(classifyUpdate(update), { kind: "message", role: "assistant", messageId: null, text: "ab" });
34
+ });
35
+
36
+ test("a chunk with no textual content is ignored (a fidelity gap, not data)", () => {
37
+ const update = { sessionUpdate: "agent_message_chunk", content: { type: "image", data: "…", mimeType: "image/png" } };
38
+ const result = classifyUpdate(update);
39
+ assert.equal(result.kind, "ignored");
40
+ });
41
+
42
+ test("tool_call → a tool-call using title as the name and rawInput as args", () => {
43
+ const update = {
44
+ sessionUpdate: "tool_call",
45
+ toolCallId: "call-1",
46
+ title: "Read config",
47
+ kind: "read",
48
+ status: "pending",
49
+ rawInput: { path: "/etc/app.conf" },
50
+ };
51
+ assert.deepEqual(classifyUpdate(update), {
52
+ kind: "tool-call",
53
+ callId: "call-1",
54
+ name: "Read config",
55
+ args: { path: "/etc/app.conf" },
56
+ });
57
+ });
58
+
59
+ test("tool_call without a title falls back to the call id for the name", () => {
60
+ const result = classifyUpdate({ sessionUpdate: "tool_call", toolCallId: "call-2" });
61
+ assert.deepEqual(result, { kind: "tool-call", callId: "call-2", name: "call-2", args: null });
62
+ });
63
+
64
+ test("tool_call without rawInput yields JSON-serialisable args (null, not dropped undefined)", () => {
65
+ const result = classifyUpdate({ sessionUpdate: "tool_call", toolCallId: "call-3" });
66
+ assert.equal(result.kind, "tool-call");
67
+ // `ToolCallEvent.args` is documented as a JSON-serialisable value; `undefined`
68
+ // is silently dropped by JSON.stringify, so the key must survive a round-trip.
69
+ const roundTripped = JSON.parse(JSON.stringify(result));
70
+ assert.ok("args" in roundTripped, "args key must survive JSON serialisation");
71
+ assert.equal(roundTripped.args, null);
72
+ });
73
+
74
+ test("tool_call_update completed → a successful tool-result carrying rawOutput", () => {
75
+ const update = { sessionUpdate: "tool_call_update", toolCallId: "call-1", status: "completed", rawOutput: { ok: 1 } };
76
+ assert.deepEqual(classifyUpdate(update), { kind: "tool-result", callId: "call-1", ok: true, result: { ok: 1 } });
77
+ });
78
+
79
+ test("tool_call_update failed → a failed tool-result falling back to content", () => {
80
+ const update = {
81
+ sessionUpdate: "tool_call_update",
82
+ toolCallId: "call-1",
83
+ status: "failed",
84
+ content: [{ type: "content", content: { type: "text", text: "boom" } }],
85
+ };
86
+ const result = classifyUpdate(update);
87
+ assert.equal(result.kind, "tool-result");
88
+ assert.equal(result.kind === "tool-result" && result.ok, false);
89
+ });
90
+
91
+ test("an intermediate tool_call_update (in_progress) is ignored", () => {
92
+ const result = classifyUpdate({ sessionUpdate: "tool_call_update", toolCallId: "call-1", status: "in_progress" });
93
+ assert.equal(result.kind, "ignored");
94
+ });
95
+
96
+ test("inherited Object.prototype keys are not treated as chunk types (no prototype pollution)", () => {
97
+ // `sessionUpdate in CHUNK_ROLE` must be an own-property check: a wire value like
98
+ // "toString"/"constructor"/"hasOwnProperty" is an inherited Object.prototype key,
99
+ // not an ACP chunk type, and must never classify a chunk with a bogus role.
100
+ for (const proto of ["toString", "constructor", "hasOwnProperty", "valueOf", "__proto__"]) {
101
+ const result = classifyUpdate({ sessionUpdate: proto, content: { type: "text", text: "x" } });
102
+ assert.equal(result.kind, "ignored", `"${proto}" must be ignored, not classified as a chunk`);
103
+ }
104
+ });
105
+
106
+ test("non-canonical updates (plan, unknown, malformed) are ignored without throwing", () => {
107
+ for (const value of [
108
+ { sessionUpdate: "plan", plan: { entries: [] } },
109
+ { sessionUpdate: "available_commands_update", availableCommands: [] },
110
+ { sessionUpdate: "usage_update", used: 10, size: 100 },
111
+ { notAnUpdate: true },
112
+ "nonsense",
113
+ null,
114
+ 42,
115
+ ]) {
116
+ assert.equal(classifyUpdate(value).kind, "ignored");
117
+ }
118
+ });
119
+
120
+ test("the classifier only ever produces the five canonical event kinds", () => {
121
+ const corpus: unknown[] = [
122
+ textChunk("agent_message_chunk", "x"),
123
+ textChunk("agent_thought_chunk", "y"),
124
+ textChunk("user_message_chunk", "z"),
125
+ { sessionUpdate: "tool_call", toolCallId: "c", title: "t" },
126
+ { sessionUpdate: "tool_call_update", toolCallId: "c", status: "completed" },
127
+ { sessionUpdate: "plan" },
128
+ ];
129
+ const producedTypes = new Set<string>();
130
+ for (const value of corpus) {
131
+ const result: AcpClassifiedUpdate = classifyUpdate(value);
132
+ if (result.kind === "message") producedTypes.add(result.role);
133
+ else producedTypes.add(result.kind);
134
+ }
135
+ producedTypes.delete("ignored");
136
+ assert.deepEqual(
137
+ [...producedTypes].sort(),
138
+ ["assistant", "reasoning", "tool-call", "tool-result", "user"],
139
+ "ACP can only reconstruct message/tool events; everything else is a documented gap",
140
+ );
141
+ });
142
+
143
+ test("ACP_FIDELITY_GAPS documents the canonical concepts ACP cannot reconstruct", () => {
144
+ assert.ok(ACP_FIDELITY_GAPS.length > 0);
145
+ const blob = ACP_FIDELITY_GAPS.map((gap) => `${gap.concept} ${gap.detail}`).join(" ").toLowerCase();
146
+ // The canonical event types ACP has no source for must each be accounted for.
147
+ for (const missing of ["usage", "compaction", "turn", "continuation", "model"]) {
148
+ assert.ok(blob.includes(missing), `fidelity gaps should mention "${missing}"`);
149
+ }
150
+ });
@@ -0,0 +1,204 @@
1
+ /**
2
+ * The ACP → canonical `SessionEvent` normaliser — ADR 0062, slice 2.
3
+ *
4
+ * A **pure, per-notification classifier**: it maps one ACP `session/update`
5
+ * payload (the `update` object, discriminated by `sessionUpdate`) to a single
6
+ * {@link AcpClassifiedUpdate}. It is deliberately free of any I/O, causal-chain,
7
+ * or coalescing concern — those belong to {@link ./client.ts}, which owns event
8
+ * identity (`id`/`parentId`) and buffers streamed chunks into whole messages.
9
+ * Keeping the wire→model mapping a pure function is what makes the ingestion
10
+ * fidelity testable in isolation (see `normalize.test.ts`).
11
+ *
12
+ * ## What maps, and what does not (ADR 0062 §5 fidelity note)
13
+ *
14
+ * The three primitives the world half needs all live in one ACP notification
15
+ * stream:
16
+ *
17
+ * - `agent_message_chunk` / `agent_thought_chunk` / `user_message_chunk` →
18
+ * canonical `assistant` / `reasoning` / `user` message text.
19
+ * - `tool_call` → a canonical `tool-call` (the request).
20
+ * - `tool_call_update` at a **terminal** status (`completed`/`failed`) → a
21
+ * canonical `tool-result`; the tool-call lifecycle is exactly the
22
+ * checkpoint/effect boundary the world half (slice 4) consumes.
23
+ *
24
+ * Everything else ACP streams — `plan`, `available_commands_update`,
25
+ * `current_mode_update`, `config_option_update`, `session_info_update`, and the
26
+ * intermediate `tool_call_update`s (status `pending`/`in_progress`) — is
27
+ * classified `ignored`: not a canonical mind event, retained only as a reason
28
+ * string for observability.
29
+ *
30
+ * The **gaps** (why slice 3's native normaliser must exist) are enumerated as a
31
+ * first-class, testable artifact in {@link ACP_FIDELITY_GAPS} — do not let that
32
+ * list drift from this mapping.
33
+ */
34
+ import { contentBlockText, isRecord } from "./protocol.ts";
35
+
36
+ /**
37
+ * The outcome of classifying one ACP `session/update`. A `message` still needs
38
+ * its chunks coalesced by the client; a `tool-call`/`tool-result` is a complete
39
+ * canonical event body; an `ignored` update carries no canonical meaning.
40
+ */
41
+ export type AcpClassifiedUpdate =
42
+ | {
43
+ readonly kind: "message";
44
+ readonly role: "assistant" | "reasoning" | "user";
45
+ /** Groups streamed chunks into one message; `null` when the agent omits it. */
46
+ readonly messageId: string | null;
47
+ readonly text: string;
48
+ }
49
+ | { readonly kind: "tool-call"; readonly callId: string; readonly name: string; readonly args: unknown }
50
+ | { readonly kind: "tool-result"; readonly callId: string; readonly ok: boolean; readonly result: unknown }
51
+ | { readonly kind: "ignored"; readonly sessionUpdate: string; readonly reason: string };
52
+
53
+ // A null-prototype map so `sessionUpdate in CHUNK_ROLE` and `CHUNK_ROLE[sessionUpdate]`
54
+ // only ever see the three explicit chunk types — a wire `sessionUpdate` like
55
+ // "toString" or "constructor" must not match an inherited Object.prototype key and
56
+ // classify a chunk with a bogus (function-valued) role.
57
+ const CHUNK_ROLE: Readonly<Record<string, "assistant" | "reasoning" | "user">> = Object.assign(
58
+ Object.create(null),
59
+ {
60
+ agent_message_chunk: "assistant",
61
+ agent_thought_chunk: "reasoning",
62
+ user_message_chunk: "user",
63
+ },
64
+ );
65
+
66
+ function ignored(sessionUpdate: string, reason: string): AcpClassifiedUpdate {
67
+ return { kind: "ignored", sessionUpdate, reason };
68
+ }
69
+
70
+ function optMessageId(update: Record<string, unknown>): string | null {
71
+ const id = update.messageId;
72
+ return typeof id === "string" ? id : null;
73
+ }
74
+
75
+ function classifyChunk(update: Record<string, unknown>, sessionUpdate: string): AcpClassifiedUpdate {
76
+ const role = CHUNK_ROLE[sessionUpdate];
77
+ const text = contentBlockText(update.content);
78
+ if (text === null) {
79
+ // A non-text content block (image/audio/resource_link) carries nothing the
80
+ // canonical text model can represent — an intentional fidelity gap, not data.
81
+ return ignored(sessionUpdate, `${role} chunk had no textual content`);
82
+ }
83
+ return { kind: "message", role, messageId: optMessageId(update), text };
84
+ }
85
+
86
+ function classifyToolCall(update: Record<string, unknown>): AcpClassifiedUpdate {
87
+ const callId = update.toolCallId;
88
+ if (typeof callId !== "string" || callId.length === 0) {
89
+ return ignored("tool_call", `tool_call missing a string "toolCallId"`);
90
+ }
91
+ // ACP has no machine tool *name* distinct from the human-readable `title`
92
+ // (ADR 0062 §5 gap); `title` is the best available identifier, falling back to
93
+ // the call id when even that is absent.
94
+ const title = update.title;
95
+ const name = typeof title === "string" && title.length > 0 ? title : callId;
96
+ // The call arguments live in `rawInput` (an opaque JSON value) when the agent
97
+ // exposes them; otherwise there is nothing structured to record. Fall back to
98
+ // `null` (never `undefined`), mirroring the tool-result `rawOutput` path: the
99
+ // canonical `ToolCallEvent.args` is a JSON-serialisable value, and `undefined`
100
+ // is dropped by `JSON.stringify`, which would violate that shape on persist/replay.
101
+ const args = "rawInput" in update && update.rawInput !== undefined ? update.rawInput : null;
102
+ return { kind: "tool-call", callId, name, args };
103
+ }
104
+
105
+ function classifyToolCallUpdate(update: Record<string, unknown>): AcpClassifiedUpdate {
106
+ const callId = update.toolCallId;
107
+ if (typeof callId !== "string" || callId.length === 0) {
108
+ return ignored("tool_call_update", `tool_call_update missing a string "toolCallId"`);
109
+ }
110
+ const status = update.status;
111
+ if (status !== "completed" && status !== "failed") {
112
+ // pending / in_progress / unknown / status-less patch — an intermediate
113
+ // lifecycle beat, not yet a canonical result.
114
+ return ignored("tool_call_update", `tool_call_update status "${String(status)}" is not terminal`);
115
+ }
116
+ // Prefer the structured `rawOutput`; fall back to the display `content` array.
117
+ const result = "rawOutput" in update ? update.rawOutput : (update.content ?? null);
118
+ return { kind: "tool-result", callId, ok: status === "completed", result };
119
+ }
120
+
121
+ /**
122
+ * Classify one ACP `update` object (the `params.update` of a `session/update`
123
+ * notification). Pure and total — every input yields a classification, malformed
124
+ * or unknown ones landing in `ignored` with a diagnostic reason rather than
125
+ * throwing, so a single odd notification can never abort an ingestion stream.
126
+ */
127
+ export function classifyUpdate(value: unknown): AcpClassifiedUpdate {
128
+ if (!isRecord(value)) {
129
+ return ignored("<none>", `update must be an object, got ${typeof value}`);
130
+ }
131
+ const sessionUpdate = value.sessionUpdate;
132
+ if (typeof sessionUpdate !== "string") {
133
+ return ignored("<none>", `update "sessionUpdate" must be a string, got ${typeof sessionUpdate}`);
134
+ }
135
+ if (sessionUpdate in CHUNK_ROLE) {
136
+ return classifyChunk(value, sessionUpdate);
137
+ }
138
+ if (sessionUpdate === "tool_call") {
139
+ return classifyToolCall(value);
140
+ }
141
+ if (sessionUpdate === "tool_call_update") {
142
+ return classifyToolCallUpdate(value);
143
+ }
144
+ return ignored(sessionUpdate, `${sessionUpdate} is not a canonical mind event`);
145
+ }
146
+
147
+ /** One place where ACP is thinner than a native transcript (ADR 0062 §5). */
148
+ export interface AcpFidelityGap {
149
+ /** The canonical concept that cannot be fully reconstructed from ACP alone. */
150
+ readonly concept: string;
151
+ /** Why ACP cannot carry it, and what a native transcript (slice 3) preserves. */
152
+ readonly detail: string;
153
+ }
154
+
155
+ /**
156
+ * The enumerated resume-fidelity gaps in the ACP backend — the documented reason
157
+ * (ADR 0062 §5) the slice 3 native normaliser exists as a fallback path. This is
158
+ * a derived source of truth: `normalize.test.ts` asserts the mapping above only
159
+ * ever emits `assistant`/`reasoning`/`user`/`tool-call`/`tool-result`, so any
160
+ * canonical event ACP *cannot* produce is accounted for here.
161
+ */
162
+ export const ACP_FIDELITY_GAPS: readonly AcpFidelityGap[] = [
163
+ {
164
+ concept: "usage / token accounting (UsageEvent)",
165
+ detail:
166
+ "ACP's usage_update reports context-window occupancy ({ used, size, cost }), " +
167
+ "not per-turn input/output token counts. It cannot reconstruct a canonical " +
168
+ "UsageEvent's inputTokens/outputTokens, so usage is dropped rather than " +
169
+ "mis-attributed; a native transcript carries the real accounting.",
170
+ },
171
+ {
172
+ concept: "reasoning continuation (ReasoningEvent.providerContinuation)",
173
+ detail:
174
+ "ACP streams agent_thought_chunk text only. It has no field for a provider's " +
175
+ "opaque encrypted reasoning-continuation blob, so a resumed incarnation cannot " +
176
+ "resume the model's chain-of-thought exactly from an ACP transcript alone.",
177
+ },
178
+ {
179
+ concept: "model identity (UsageEvent.model)",
180
+ detail:
181
+ "ACP does not attribute a session/update to a specific provider model id, so " +
182
+ "the model a turn ran on is not recoverable from the ACP stream.",
183
+ },
184
+ {
185
+ concept: "tool name vs. title (ToolCallEvent.name)",
186
+ detail:
187
+ "ACP's tool_call exposes a human-readable `title` and a `kind` category but no " +
188
+ "stable machine tool name; the normaliser records `title` as the name, which is " +
189
+ "a display string, not a canonical tool identifier.",
190
+ },
191
+ {
192
+ concept: "compaction / truncation boundaries (CompactionEvent)",
193
+ detail:
194
+ "ACP has no notification for a context compaction or truncation, so a replayed " +
195
+ "ACP history cannot mark the offset ranges a native transcript folds into a summary.",
196
+ },
197
+ {
198
+ concept: "explicit turn boundaries (TurnStartEvent / TurnEndEvent)",
199
+ detail:
200
+ "ACP models a prompt's end via a state/stopReason on the session/prompt response, " +
201
+ "not as numbered turn-start/turn-end markers in the update stream, so canonical " +
202
+ "turn indices are not reconstructable from session/update notifications alone.",
203
+ },
204
+ ];