@nanobpm/agentic 0.1.0 → 0.4.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 (117) hide show
  1. package/README.md +1 -0
  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/package.json +17 -1
  59. package/src/demand/model.test.ts +82 -4
  60. package/src/demand/model.ts +30 -9
  61. package/src/demand/taskdef.test.ts +51 -6
  62. package/src/demand/taskdef.ts +31 -2
  63. package/src/index.ts +1 -0
  64. package/src/protocol/conformance/frames.ts +32 -4
  65. package/src/protocol/index.ts +4 -0
  66. package/src/protocol/payloads.test.ts +31 -1
  67. package/src/protocol/payloads.ts +110 -7
  68. package/src/session/acp/client.test.ts +222 -0
  69. package/src/session/acp/client.ts +356 -0
  70. package/src/session/acp/fake-agent.ts +71 -0
  71. package/src/session/acp/index.ts +68 -0
  72. package/src/session/acp/integration.test.ts +37 -0
  73. package/src/session/acp/jsonrpc.test.ts +75 -0
  74. package/src/session/acp/jsonrpc.ts +171 -0
  75. package/src/session/acp/normalize.test.ts +150 -0
  76. package/src/session/acp/normalize.ts +204 -0
  77. package/src/session/acp/protocol.ts +178 -0
  78. package/src/session/acp/spawn.test.ts +45 -0
  79. package/src/session/acp/spawn.ts +91 -0
  80. package/src/session/acp/transport.test.ts +82 -0
  81. package/src/session/acp/transport.ts +155 -0
  82. package/src/session/adapter.ts +159 -0
  83. package/src/session/backend.test.ts +198 -0
  84. package/src/session/backend.ts +128 -0
  85. package/src/session/events.test.ts +168 -0
  86. package/src/session/events.ts +347 -0
  87. package/src/session/index.ts +67 -0
  88. package/src/session/log.test.ts +215 -0
  89. package/src/session/log.ts +525 -0
  90. package/src/session/normalizer/backend-integration.test.ts +103 -0
  91. package/src/session/normalizer/claude.test.ts +68 -0
  92. package/src/session/normalizer/claude.ts +136 -0
  93. package/src/session/normalizer/copilot.test.ts +59 -0
  94. package/src/session/normalizer/copilot.ts +133 -0
  95. package/src/session/normalizer/deepseek.ts +80 -0
  96. package/src/session/normalizer/index.ts +61 -0
  97. package/src/session/normalizer/kimi.ts +82 -0
  98. package/src/session/normalizer/link.test.ts +24 -0
  99. package/src/session/normalizer/link.ts +81 -0
  100. package/src/session/normalizer/pi.ts +75 -0
  101. package/src/session/normalizer/probe.test.ts +49 -0
  102. package/src/session/normalizer/qwen.test.ts +20 -0
  103. package/src/session/normalizer/qwen.ts +77 -0
  104. package/src/session/normalizer/record.test.ts +68 -0
  105. package/src/session/normalizer/record.ts +88 -0
  106. package/src/session/normalizer/resume.test.ts +25 -0
  107. package/src/session/normalizer/types.ts +152 -0
  108. package/src/session/normalizer/vectors.test.ts +180 -0
  109. package/src/session/schema.test.ts +84 -0
  110. package/src/session/schema.ts +78 -0
  111. package/src/session/test-db.ts +56 -0
  112. package/dist/blackboard/test-db.d.ts +0 -5
  113. package/dist/blackboard/test-db.js +0 -42
  114. package/dist/presence/test-db.d.ts +0 -5
  115. package/dist/presence/test-db.js +0 -42
  116. package/dist/transcript/test-db.d.ts +0 -5
  117. package/dist/transcript/test-db.js +0 -41
@@ -0,0 +1,356 @@
1
+ /**
2
+ * The ACP ingestion backend — ADR 0062, slice 2 (the preferred adapter target).
3
+ *
4
+ * {@link AcpSessionClient} connects an ACP-speaking harness (reference:
5
+ * `opencode acp`; also Claude via `claude-code-acp`, the Gemini lineage) to a
6
+ * Slice-1 {@link SessionEventSink} — normalising the harness's `session/update`
7
+ * stream into canonical {@link SessionEvent}s and driving/steering the session
8
+ * over JSON-RPC. It realises the three ADR 0062 §5 primitives:
9
+ *
10
+ * - `session/update` → **emit**: each notification is classified
11
+ * ({@link classifyUpdate}), streamed message chunks are coalesced into whole
12
+ * `assistant`/`reasoning`/`user` events, and the `tool_call`/`tool_call_update`
13
+ * lifecycle becomes canonical `tool-call`/`tool-result` events.
14
+ * - `session/load` → **restore**: replays the harness's prior history (delivered
15
+ * as `session/update`s during the load) back into the sink.
16
+ * - `agentCapabilities.loadSession` (from `initialize`) → the **durable-resume
17
+ * capability probe** the nano-workforce enrolment gate (slice 5) consumes.
18
+ *
19
+ * This backend owns event **identity** and **causality** (the `id`/`parentId`
20
+ * chain) exactly as `events.ts` prescribes — ACP carries neither — while the sink
21
+ * (a {@link SessionAdapter}) owns ordering and fencing. Keeping those split lets a
22
+ * `SessionBackend` be passed straight in as the sink with no adapter.
23
+ */
24
+ import { randomUUID } from "node:crypto";
25
+ import type { SessionEvent } from "../events.ts";
26
+ import { AcpConnection } from "./jsonrpc.ts";
27
+ import { type AcpClassifiedUpdate, classifyUpdate } from "./normalize.ts";
28
+ import {
29
+ ACP_CLIENT_METHOD,
30
+ ACP_METHOD,
31
+ ACP_PROTOCOL_VERSION,
32
+ type AcpPromptCapabilities,
33
+ isRecord,
34
+ parseInitializeResult,
35
+ parseSessionId,
36
+ } from "./protocol.ts";
37
+ import type { AcpTransport } from "./transport.ts";
38
+
39
+ /**
40
+ * The narrow port the ingestion client writes canonical events to. A Slice-1
41
+ * {@link SessionAdapter} (e.g. a `SessionBackend`) satisfies it structurally, so
42
+ * the ACP stream can feed the authoritative log directly; a test can pass a
43
+ * plain collector.
44
+ */
45
+ export interface SessionEventSink {
46
+ emit(event: SessionEvent): void;
47
+ }
48
+
49
+ /** A single ACP content block sent in a prompt. `text` is the baseline shape. */
50
+ export interface AcpTextContentBlock {
51
+ readonly type: "text";
52
+ readonly text: string;
53
+ }
54
+
55
+ /** The prompt payload: a content-block array, or a bare string (wrapped as text). */
56
+ export type AcpPromptInput = string | readonly AcpTextContentBlock[];
57
+
58
+ /** The durable-resume capability probe read from the `initialize` handshake. */
59
+ export interface AcpCapabilityProbe {
60
+ /** The protocol version the agent negotiated. */
61
+ readonly protocolVersion: number;
62
+ /** The raw `agentCapabilities.loadSession` flag. */
63
+ readonly loadSession: boolean;
64
+ /**
65
+ * The nano-workforce enrolment-gate signal (slice 5): `true` iff the agent can
66
+ * durably resume via `session/load`. Equal to {@link loadSession} — named for
67
+ * the gate that consumes it, so the enrolment site reads intent, not wire detail.
68
+ */
69
+ readonly durableResume: boolean;
70
+ /** The agent's advertised prompt-content capabilities. */
71
+ readonly promptCapabilities: AcpPromptCapabilities;
72
+ /** The full, unmodified `agentCapabilities` object for consumers that need more. */
73
+ readonly agentCapabilities: unknown;
74
+ }
75
+
76
+ /** The result of a completed `session/prompt`, plus the events it produced. */
77
+ export interface AcpPromptResult {
78
+ /** The agent's stop reason (`end_turn`, `cancelled`, …) when it reports one. */
79
+ readonly stopReason: string | null;
80
+ /** The canonical events emitted while this prompt ran, in emission order. */
81
+ readonly events: readonly SessionEvent[];
82
+ }
83
+
84
+ /** Parameters shared by `session/new` and `session/load`. */
85
+ export interface AcpSessionParams {
86
+ /** The session working directory (absolute path). */
87
+ readonly cwd: string;
88
+ /** MCP servers to attach; defaults to none. */
89
+ readonly mcpServers?: readonly unknown[];
90
+ }
91
+
92
+ export interface AcpSessionClientOptions {
93
+ /** Injectable event-id generator (deterministic tests). Default `crypto.randomUUID`. */
94
+ readonly newEventId?: () => string;
95
+ /** Client name/version reported in `initialize`. */
96
+ readonly clientInfo?: { readonly name: string; readonly version: string };
97
+ /**
98
+ * How to answer an agent's `session/request_permission`. Default: select the
99
+ * first "allow"-flavoured option the agent offers (or a cancel outcome when
100
+ * none is), so a driven session is never silently blocked on approval.
101
+ */
102
+ readonly onPermissionRequest?: (params: unknown) => unknown;
103
+ }
104
+
105
+ interface MessageBuffer {
106
+ readonly role: "assistant" | "reasoning" | "user";
107
+ readonly messageId: string | null;
108
+ readonly text: string;
109
+ }
110
+
111
+ /**
112
+ * The ACP ingestion client. Bind it to a transport and a sink, `initialize`, then
113
+ * either `newSession` + `prompt` (drive) or `restore` an existing session id.
114
+ */
115
+ export class AcpSessionClient {
116
+ readonly #connection: AcpConnection;
117
+ readonly #sink: SessionEventSink;
118
+ readonly #newEventId: () => string;
119
+ readonly #clientInfo: { name: string; version: string };
120
+ #lastId: string | null = null;
121
+ #buffer: MessageBuffer | undefined;
122
+ #collector: SessionEvent[] | null = null;
123
+ #sessionId: string | undefined;
124
+
125
+ constructor(connection: AcpConnection, sink: SessionEventSink, options: AcpSessionClientOptions = {}) {
126
+ this.#connection = connection;
127
+ this.#sink = sink;
128
+ this.#newEventId = options.newEventId ?? randomUUID;
129
+ this.#clientInfo = options.clientInfo ?? { name: "nano-agentic", version: "0" };
130
+ const permit = options.onPermissionRequest ?? defaultPermissionResponse;
131
+ connection.onNotification(ACP_CLIENT_METHOD.sessionUpdate, (params) => this.#ingest(params));
132
+ connection.onRequest(ACP_CLIENT_METHOD.requestPermission, (params) => permit(params));
133
+ }
134
+
135
+ /** The active session id (after `newSession`/`restore`), or `undefined`. */
136
+ get sessionId(): string | undefined {
137
+ return this.#sessionId;
138
+ }
139
+
140
+ /**
141
+ * Perform the `initialize` handshake and return the durable-resume capability
142
+ * probe. Must be called before any `session/*` method.
143
+ */
144
+ async initialize(): Promise<AcpCapabilityProbe> {
145
+ const result = await this.#connection.request(ACP_METHOD.initialize, {
146
+ protocolVersion: ACP_PROTOCOL_VERSION,
147
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
148
+ clientInfo: this.#clientInfo,
149
+ });
150
+ const parsed = parseInitializeResult(result);
151
+ return {
152
+ protocolVersion: parsed.protocolVersion,
153
+ loadSession: parsed.agentCapabilities.loadSession,
154
+ durableResume: parsed.agentCapabilities.loadSession,
155
+ promptCapabilities: parsed.agentCapabilities.promptCapabilities,
156
+ agentCapabilities: parsed.rawAgentCapabilities,
157
+ };
158
+ }
159
+
160
+ /** Open a fresh session (`session/new`); stores and returns its id. */
161
+ async newSession(params: AcpSessionParams): Promise<string> {
162
+ const result = await this.#connection.request(ACP_METHOD.sessionNew, {
163
+ cwd: params.cwd,
164
+ mcpServers: params.mcpServers ?? [],
165
+ });
166
+ this.#sessionId = parseSessionId(result);
167
+ return this.#sessionId;
168
+ }
169
+
170
+ /**
171
+ * **restore** — load an existing session (`session/load`). The agent replays its
172
+ * prior history as `session/update` notifications, which this client ingests
173
+ * into the sink; when the load resolves, the pending message is flushed. Returns
174
+ * the canonical events reconstructed from the replayed history, in order.
175
+ *
176
+ * Only valid against an agent whose probe reported `durableResume: true`.
177
+ */
178
+ async restore(sessionId: string, params: AcpSessionParams): Promise<readonly SessionEvent[]> {
179
+ const collected = await this.#collecting(() =>
180
+ this.#connection.request(ACP_METHOD.sessionLoad, {
181
+ sessionId,
182
+ cwd: params.cwd,
183
+ mcpServers: params.mcpServers ?? [],
184
+ }),
185
+ );
186
+ this.#sessionId = sessionId;
187
+ return collected.events;
188
+ }
189
+
190
+ /**
191
+ * **drive** — send a prompt (`session/prompt`) and resolve when the turn ends.
192
+ * All assistant output arrives as `session/update` notifications and is emitted
193
+ * to the sink during the call; the final buffered message is flushed on
194
+ * completion.
195
+ */
196
+ async prompt(input: AcpPromptInput): Promise<AcpPromptResult> {
197
+ if (this.#sessionId === undefined) {
198
+ throw new Error("prompt requires an active session — call newSession() or restore() first");
199
+ }
200
+ const prompt = typeof input === "string" ? [{ type: "text", text: input }] : input;
201
+ const collected = await this.#collecting(() =>
202
+ this.#connection.request(ACP_METHOD.sessionPrompt, { sessionId: this.#sessionId, prompt }),
203
+ );
204
+ return { stopReason: readStopReason(collected.result), events: collected.events };
205
+ }
206
+
207
+ /** **steer** — request cancellation of the running turn (`session/cancel`, a notification). */
208
+ cancel(): void {
209
+ if (this.#sessionId === undefined) return;
210
+ this.#connection.notify(ACP_METHOD.sessionCancel, { sessionId: this.#sessionId });
211
+ }
212
+
213
+ /** Close the underlying connection and flush any pending buffered message. */
214
+ close(): void {
215
+ this.#flushMessage();
216
+ this.#connection.close();
217
+ }
218
+
219
+ async #collecting(op: () => Promise<unknown>): Promise<{ result: unknown; events: SessionEvent[] }> {
220
+ const events: SessionEvent[] = [];
221
+ const previous = this.#collector;
222
+ this.#collector = events;
223
+ try {
224
+ const result = await op();
225
+ // Every session/update queued before the response has been ingested by now
226
+ // (ordered transport delivery); flush the final in-flight message.
227
+ this.#flushMessage();
228
+ return { result, events };
229
+ } finally {
230
+ this.#collector = previous;
231
+ }
232
+ }
233
+
234
+ #ingest(params: unknown): void {
235
+ if (!isRecord(params)) return;
236
+ // session/update carries the target sessionId (ACP). Once this client is driving
237
+ // a session, ignore updates addressed to a *different* session — a late update
238
+ // from a previous session, or a misrouted one, must not corrupt this stream.
239
+ // Guarded on #sessionId being set: during session/load replay the id is not yet
240
+ // stored, and those updates legitimately belong to the session being restored.
241
+ const sessionId = params.sessionId;
242
+ if (this.#sessionId !== undefined && typeof sessionId === "string" && sessionId !== this.#sessionId) {
243
+ return;
244
+ }
245
+ const update = classifyUpdate(params.update);
246
+ switch (update.kind) {
247
+ case "message":
248
+ this.#appendMessage(update);
249
+ return;
250
+ case "tool-call": {
251
+ this.#flushMessage();
252
+ const { id, parentId } = this.#nextIdentity();
253
+ this.#push({ type: "tool-call", id, parentId, callId: update.callId, name: update.name, args: update.args });
254
+ return;
255
+ }
256
+ case "tool-result": {
257
+ this.#flushMessage();
258
+ const { id, parentId } = this.#nextIdentity();
259
+ this.#push({ type: "tool-result", id, parentId, callId: update.callId, ok: update.ok, result: update.result });
260
+ return;
261
+ }
262
+ case "ignored":
263
+ return;
264
+ }
265
+ }
266
+
267
+ #appendMessage(update: Extract<AcpClassifiedUpdate, { kind: "message" }>): void {
268
+ const buffer = this.#buffer;
269
+ if (buffer !== undefined && buffer.role === update.role && sameMessage(buffer.messageId, update.messageId)) {
270
+ this.#buffer = { role: buffer.role, messageId: buffer.messageId, text: buffer.text + update.text };
271
+ return;
272
+ }
273
+ this.#flushMessage();
274
+ this.#buffer = { role: update.role, messageId: update.messageId, text: update.text };
275
+ }
276
+
277
+ #flushMessage(): void {
278
+ const buffer = this.#buffer;
279
+ if (buffer === undefined) return;
280
+ this.#buffer = undefined;
281
+ const { id, parentId } = this.#nextIdentity();
282
+ switch (buffer.role) {
283
+ case "assistant":
284
+ this.#push({ type: "assistant", id, parentId, text: buffer.text });
285
+ return;
286
+ case "reasoning":
287
+ this.#push({ type: "reasoning", id, parentId, text: buffer.text });
288
+ return;
289
+ case "user":
290
+ this.#push({ type: "user", id, parentId, text: buffer.text });
291
+ return;
292
+ }
293
+ }
294
+
295
+ #nextIdentity(): { id: string; parentId: string | null } {
296
+ const id = this.#newEventId();
297
+ const parentId = this.#lastId;
298
+ this.#lastId = id;
299
+ return { id, parentId };
300
+ }
301
+
302
+ #push(event: SessionEvent): void {
303
+ this.#sink.emit(event);
304
+ this.#collector?.push(event);
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Two message chunks belong to the same logical message when their `messageId`s
310
+ * are equal, or when neither carries one (the agent omitted it) — in which case
311
+ * consecutive same-role chunks coalesce until a role change or a tool boundary.
312
+ */
313
+ function sameMessage(a: string | null, b: string | null): boolean {
314
+ if (a === null && b === null) return true;
315
+ return a === b;
316
+ }
317
+
318
+ function readStopReason(result: unknown): string | null {
319
+ if (isRecord(result) && typeof result.stopReason === "string") return result.stopReason;
320
+ return null;
321
+ }
322
+
323
+ /**
324
+ * Select the first option whose kind reads as an approval from a
325
+ * `session/request_permission` request, so a driven session proceeds without a
326
+ * human in the loop; cancel when the agent offers no allow option. Full
327
+ * interactive-permission and client-side `fs`/`terminal` support is out of this
328
+ * slice's scope (this backend advertises neither capability at `initialize`).
329
+ */
330
+ function defaultPermissionResponse(params: unknown): unknown {
331
+ if (isRecord(params) && Array.isArray(params.options)) {
332
+ for (const option of params.options) {
333
+ if (!isRecord(option)) continue;
334
+ const kind = option.kind;
335
+ if (typeof kind === "string" && kind.startsWith("allow") && typeof option.optionId === "string") {
336
+ return { outcome: { outcome: "selected", optionId: option.optionId } };
337
+ }
338
+ }
339
+ }
340
+ // No "allow"-flavoured option (or no options at all): cancel, as documented,
341
+ // rather than silently selecting an arbitrary — possibly deny — option.
342
+ return { outcome: { outcome: "cancelled" } };
343
+ }
344
+
345
+ /**
346
+ * Open an ACP ingestion client over `transport`, emitting into `sink`. Wraps the
347
+ * transport in an {@link AcpConnection} and returns the ready client; call
348
+ * {@link AcpSessionClient.initialize} first.
349
+ */
350
+ export function openAcpSession(
351
+ transport: AcpTransport,
352
+ sink: SessionEventSink,
353
+ options: AcpSessionClientOptions = {},
354
+ ): AcpSessionClient {
355
+ return new AcpSessionClient(new AcpConnection(transport), sink, options);
356
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * A scriptable in-memory fake ACP agent — test-only (excluded from the build,
3
+ * like `../test-db.ts`). It plays the *agent* side of the ACP peer over an
4
+ * {@link AcpTransport} so the client's `initialize`/`session/*` flow and the
5
+ * `session/update` → canonical `SessionEvent` normalisation can be exercised
6
+ * end-to-end without spawning a real `opencode acp` process.
7
+ *
8
+ * It reuses the production {@link AcpConnection} for its own peer, so the tests
9
+ * drive the exact JSON-RPC wire the real harness would.
10
+ */
11
+ import { AcpConnection } from "./jsonrpc.ts";
12
+ import { isRecord } from "./protocol.ts";
13
+ import type { AcpTransport } from "./transport.ts";
14
+
15
+ export interface FakeAcpAgentScript {
16
+ /** Advertised `agentCapabilities.loadSession` (the durable-resume probe). Default `false`. */
17
+ readonly loadSession?: boolean;
18
+ /** The protocol version the agent negotiates. Default `1`. */
19
+ readonly protocolVersion?: number;
20
+ /** `update` objects the agent streams (as `session/update`) while a prompt runs. */
21
+ readonly promptUpdates?: readonly unknown[];
22
+ /** The `stopReason` the `session/prompt` resolves with. Default `"end_turn"`. */
23
+ readonly promptStopReason?: string;
24
+ /** `update` objects the agent replays (as `session/update`) during `session/load`. */
25
+ readonly loadUpdates?: readonly unknown[];
26
+ /** The session id `session/new` hands out. Default `"sess-fake"`. */
27
+ readonly sessionId?: string;
28
+ }
29
+
30
+ function sessionIdOf(params: unknown, fallback: string): string {
31
+ if (isRecord(params) && typeof params.sessionId === "string") {
32
+ return params.sessionId;
33
+ }
34
+ return fallback;
35
+ }
36
+
37
+ /** Wire a fake agent onto `transport` and return its connection (for `close()`). */
38
+ export function startFakeAcpAgent(transport: AcpTransport, script: FakeAcpAgentScript = {}): AcpConnection {
39
+ const connection = new AcpConnection(transport);
40
+ const sessionId = script.sessionId ?? "sess-fake";
41
+ const loadSession = script.loadSession ?? false;
42
+
43
+ connection.onRequest("initialize", () => ({
44
+ protocolVersion: script.protocolVersion ?? 1,
45
+ agentCapabilities: {
46
+ loadSession,
47
+ promptCapabilities: { image: false, audio: false, embeddedContext: false },
48
+ },
49
+ authMethods: [],
50
+ }));
51
+
52
+ connection.onRequest("session/new", () => ({ sessionId }));
53
+
54
+ connection.onRequest("session/load", (params) => {
55
+ const sid = sessionIdOf(params, sessionId);
56
+ for (const update of script.loadUpdates ?? []) {
57
+ connection.notify("session/update", { sessionId: sid, update });
58
+ }
59
+ return { modes: null, configOptions: null };
60
+ });
61
+
62
+ connection.onRequest("session/prompt", (params) => {
63
+ const sid = sessionIdOf(params, sessionId);
64
+ for (const update of script.promptUpdates ?? []) {
65
+ connection.notify("session/update", { sessionId: sid, update });
66
+ }
67
+ return { stopReason: script.promptStopReason ?? "end_turn" };
68
+ });
69
+
70
+ return connection;
71
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `@nanobpm/agentic/session/acp` — the ACP ingestion backend (ADR 0062, slice 2).
3
+ *
4
+ * The preferred harness-facing backend of the `@nanobpm/agentic/session` contract
5
+ * (ADR 0062 §5): an Agent Client Protocol (protocol v1) client that connects to an
6
+ * ACP-speaking harness (`opencode acp`, `claude-code-acp`, the Gemini lineage),
7
+ * runs the `initialize` handshake to probe `agentCapabilities.loadSession`, and
8
+ * normalises the `session/update` stream into canonical {@link SessionEvent}s —
9
+ * driving via `session/prompt`, steering via `session/cancel`, and **restoring**
10
+ * prior history via `session/load`.
11
+ *
12
+ * Exports, in the order a consumer meets them:
13
+ * - {@link openAcpSession} / {@link AcpSessionClient} — the ingestion client and
14
+ * its capability probe / prompt-result types;
15
+ * - {@link classifyUpdate} + {@link ACP_FIDELITY_GAPS} — the pure wire→canonical
16
+ * normaliser and the enumerated ADR 0062 §5 fidelity gaps that justify slice 3;
17
+ * - the {@link AcpConnection} JSON-RPC peer and the {@link AcpTransport} seam,
18
+ * with {@link spawnAcpTransport} (a real harness subprocess) and
19
+ * {@link inMemoryTransportPair} (an in-memory peer for tests);
20
+ * - the ACP wire vocabulary ({@link ACP_METHOD}, guards) from `protocol.ts`.
21
+ */
22
+ export {
23
+ AcpSessionClient,
24
+ type AcpCapabilityProbe,
25
+ type AcpPromptInput,
26
+ type AcpPromptResult,
27
+ type AcpSessionClientOptions,
28
+ type AcpSessionParams,
29
+ type AcpTextContentBlock,
30
+ openAcpSession,
31
+ type SessionEventSink,
32
+ } from "./client.ts";
33
+
34
+ export {
35
+ ACP_FIDELITY_GAPS,
36
+ type AcpClassifiedUpdate,
37
+ type AcpFidelityGap,
38
+ classifyUpdate,
39
+ } from "./normalize.ts";
40
+
41
+ export {
42
+ AcpConnection,
43
+ type AcpNotificationHandler,
44
+ type AcpRequestHandler,
45
+ AcpRpcError,
46
+ } from "./jsonrpc.ts";
47
+
48
+ export {
49
+ type AcpTransport,
50
+ encodeMessageLine,
51
+ inMemoryTransportPair,
52
+ NewlineJsonDecoder,
53
+ } from "./transport.ts";
54
+
55
+ export { type SpawnAcpOptions, type SpawnedAcpTransport, spawnAcpTransport } from "./spawn.ts";
56
+
57
+ export {
58
+ ACP_CLIENT_METHOD,
59
+ ACP_METHOD,
60
+ ACP_PROTOCOL_VERSION,
61
+ type AcpAgentCapabilities,
62
+ type AcpInitializeResult,
63
+ type AcpPromptCapabilities,
64
+ AcpProtocolError,
65
+ contentBlockText,
66
+ parseInitializeResult,
67
+ parseSessionId,
68
+ } from "./protocol.ts";
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Integration test against a *real* ACP harness — ADR 0062, slice 2 acceptance.
3
+ *
4
+ * Skipped by default: it only runs when `ACP_OPENCODE_CMD` names a real
5
+ * ACP-speaking binary (e.g. `ACP_OPENCODE_CMD=opencode ACP_OPENCODE_ARGS=acp`),
6
+ * so CI stays hermetic while the acceptance path ("against a real `opencode acp`
7
+ * process: `initialize` reports `loadSession`") remains exercisable on demand.
8
+ */
9
+ import assert from "node:assert/strict";
10
+ import { test } from "node:test";
11
+ import { AcpSessionClient } from "./client.ts";
12
+ import { AcpConnection } from "./jsonrpc.ts";
13
+ import { spawnAcpTransport } from "./spawn.ts";
14
+
15
+ const command = process.env.ACP_OPENCODE_CMD;
16
+ const args = process.env.ACP_OPENCODE_ARGS
17
+ ? process.env.ACP_OPENCODE_ARGS.trim().split(/\s+/).filter(Boolean)
18
+ : ["acp"];
19
+
20
+ test(
21
+ "initialize against a real ACP harness reports its loadSession capability",
22
+ { skip: command ? false : "set ACP_OPENCODE_CMD to run the real-harness integration test" },
23
+ async () => {
24
+ assert.ok(command);
25
+ const transport = spawnAcpTransport({ command, args, cwd: process.cwd() });
26
+ const client = new AcpSessionClient(new AcpConnection(transport), { emit: () => {} });
27
+ try {
28
+ const probe = await client.initialize();
29
+ assert.equal(typeof probe.loadSession, "boolean");
30
+ assert.equal(probe.durableResume, probe.loadSession);
31
+ assert.ok(Number.isInteger(probe.protocolVersion));
32
+ } finally {
33
+ client.close();
34
+ transport.close();
35
+ }
36
+ },
37
+ );
@@ -0,0 +1,75 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { AcpConnection, AcpRpcError } from "./jsonrpc.ts";
4
+ import { inMemoryTransportPair } from "./transport.ts";
5
+
6
+ test("a request resolves with the peer's result", async () => {
7
+ const { client, agent } = inMemoryTransportPair();
8
+ const clientConn = new AcpConnection(client);
9
+ const agentConn = new AcpConnection(agent);
10
+ agentConn.onRequest("ping", (params) => ({ echoed: params }));
11
+
12
+ const result = await clientConn.request("ping", { n: 1 });
13
+ assert.deepEqual(result, { echoed: { n: 1 } });
14
+ });
15
+
16
+ test("a request rejects with an AcpRpcError when the peer returns an error", async () => {
17
+ const { client, agent } = inMemoryTransportPair();
18
+ const clientConn = new AcpConnection(client);
19
+ const agentConn = new AcpConnection(agent);
20
+ agentConn.onRequest("boom", () => {
21
+ throw new AcpRpcError(-32000, "kaboom", { extra: true });
22
+ });
23
+
24
+ await assert.rejects(
25
+ () => clientConn.request("boom"),
26
+ (error: unknown) => {
27
+ assert.ok(error instanceof AcpRpcError);
28
+ assert.equal(error.code, -32000);
29
+ assert.equal(error.message, "kaboom");
30
+ assert.deepEqual(error.data, { extra: true });
31
+ return true;
32
+ },
33
+ );
34
+ });
35
+
36
+ test("an unhandled inbound request is answered with method-not-found", async () => {
37
+ const { client, agent } = inMemoryTransportPair();
38
+ const clientConn = new AcpConnection(client);
39
+ // The agent side registers no handler for "unknown/method".
40
+ new AcpConnection(agent);
41
+
42
+ await assert.rejects(
43
+ () => clientConn.request("unknown/method"),
44
+ (error: unknown) => {
45
+ assert.ok(error instanceof AcpRpcError);
46
+ assert.equal(error.code, -32601);
47
+ return true;
48
+ },
49
+ );
50
+ });
51
+
52
+ test("notifications are dispatched to the registered handler and expect no response", async () => {
53
+ const { client, agent } = inMemoryTransportPair();
54
+ const clientConn = new AcpConnection(client);
55
+ const agentConn = new AcpConnection(agent);
56
+ const received: unknown[] = [];
57
+ agentConn.onNotification("event", (params) => received.push(params));
58
+
59
+ clientConn.notify("event", { tick: 1 });
60
+ await Promise.resolve();
61
+ await Promise.resolve();
62
+ assert.deepEqual(received, [{ tick: 1 }]);
63
+ });
64
+
65
+ test("closing a connection rejects every in-flight request", async () => {
66
+ const { client, agent } = inMemoryTransportPair();
67
+ const clientConn = new AcpConnection(client);
68
+ const agentConn = new AcpConnection(agent);
69
+ // A handler that never resolves, so the request stays in flight until close.
70
+ agentConn.onRequest("hang", () => new Promise(() => {}));
71
+
72
+ const pending = clientConn.request("hang");
73
+ clientConn.close();
74
+ await assert.rejects(() => pending, /closed/);
75
+ });