@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,178 @@
1
+ /**
2
+ * The ACP (Agent Client Protocol) wire vocabulary — ADR 0062, slice 2.
3
+ *
4
+ * This is the **harness-facing** half of the ACP ingestion backend: the JSON-RPC
5
+ * method names, the protocol-version constant, and the small set of runtime
6
+ * guards that turn the untyped JSON a harness sends over the wire into the typed
7
+ * shapes {@link ./normalize.ts} and {@link ./client.ts} consume. Exactly like
8
+ * `../events.ts`' `parseSessionEvent`, every guard *builds* a typed value field
9
+ * by field and fails loudly on a malformed one — it never uses an `as`-cast to
10
+ * fabricate a shape (AGENTS.md: the `as T` ban applies at every untyped boundary).
11
+ *
12
+ * The shapes mirror ACP **protocol version 1** — the version `opencode acp`
13
+ * speaks and the one ADR 0062 §5 resolves as the preferred harness protocol.
14
+ * Method names are verified against the canonical schema
15
+ * (`zed-industries/agent-client-protocol`, `schema/v1/meta.json`) and confirmed
16
+ * present in the opencode binary: `initialize`, `session/new|load|prompt|cancel`,
17
+ * `session/update`.
18
+ */
19
+
20
+ /** The ACP protocol version this client negotiates (integer, per the spec). */
21
+ export const ACP_PROTOCOL_VERSION = 1;
22
+
23
+ /** Agent-handled JSON-RPC methods (client → agent requests / notifications). */
24
+ export const ACP_METHOD = {
25
+ initialize: "initialize",
26
+ sessionNew: "session/new",
27
+ sessionLoad: "session/load",
28
+ sessionPrompt: "session/prompt",
29
+ /** A notification — no response is expected. */
30
+ sessionCancel: "session/cancel",
31
+ } as const;
32
+
33
+ /** Client-handled JSON-RPC methods (agent → client requests / notifications). */
34
+ export const ACP_CLIENT_METHOD = {
35
+ /** Streamed session activity — a notification the agent pushes to the client. */
36
+ sessionUpdate: "session/update",
37
+ /** The agent asks the client to approve a tool call. */
38
+ requestPermission: "session/request_permission",
39
+ } as const;
40
+
41
+ /** A JSON object with unknown-typed values — the raw wire shape before guarding. */
42
+ export type JsonRecord = Record<string, unknown>;
43
+
44
+ /** Narrow an unknown value to a plain (non-array) object. */
45
+ export function isRecord(value: unknown): value is JsonRecord {
46
+ return typeof value === "object" && value !== null && !Array.isArray(value);
47
+ }
48
+
49
+ /**
50
+ * The prompt-content capabilities an agent advertises (`agentCapabilities`
51
+ * sub-object). All optional booleans, defaulting to `false` when absent.
52
+ */
53
+ export interface AcpPromptCapabilities {
54
+ readonly image: boolean;
55
+ readonly audio: boolean;
56
+ readonly embeddedContext: boolean;
57
+ }
58
+
59
+ /**
60
+ * The subset of `agentCapabilities` this slice reads from the `initialize`
61
+ * handshake. `loadSession` is the ADR 0062 §5 durable-resume probe; the rest is
62
+ * retained verbatim in {@link AcpInitializeResult.rawAgentCapabilities} for
63
+ * consumers that need more.
64
+ */
65
+ export interface AcpAgentCapabilities {
66
+ /** Whether the agent supports `session/load` — the durable-resume signal. */
67
+ readonly loadSession: boolean;
68
+ readonly promptCapabilities: AcpPromptCapabilities;
69
+ }
70
+
71
+ /** The negotiated result of an `initialize` handshake, guarded off the wire. */
72
+ export interface AcpInitializeResult {
73
+ readonly protocolVersion: number;
74
+ readonly agentCapabilities: AcpAgentCapabilities;
75
+ /** The full, unmodified `agentCapabilities` object (opaque passthrough). */
76
+ readonly rawAgentCapabilities: unknown;
77
+ }
78
+
79
+ function optBool(record: JsonRecord, field: string): boolean {
80
+ return record[field] === true;
81
+ }
82
+
83
+ function readPromptCapabilities(value: unknown): AcpPromptCapabilities {
84
+ if (!isRecord(value)) {
85
+ return { image: false, audio: false, embeddedContext: false };
86
+ }
87
+ return {
88
+ image: optBool(value, "image"),
89
+ audio: optBool(value, "audio"),
90
+ embeddedContext: optBool(value, "embeddedContext"),
91
+ };
92
+ }
93
+
94
+ /** Raised when an ACP wire message is not the well-formed shape its method requires. */
95
+ export class AcpProtocolError extends Error {
96
+ constructor(message: string) {
97
+ super(message);
98
+ this.name = "AcpProtocolError";
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Parse an `initialize` result. The agent MAY answer with a lower
104
+ * `protocolVersion` than requested (down-negotiation); we surface whatever it
105
+ * reports and let the caller decide. A missing `agentCapabilities` is treated as
106
+ * "no capabilities" (every flag `false`) rather than an error, matching the ACP
107
+ * schema's `x-deserialize-default-on-error` default.
108
+ */
109
+ export function parseInitializeResult(value: unknown): AcpInitializeResult {
110
+ if (!isRecord(value)) {
111
+ throw new AcpProtocolError(`initialize result must be an object, got ${typeof value}`);
112
+ }
113
+ const protocolVersion = value.protocolVersion;
114
+ if (typeof protocolVersion !== "number" || !Number.isInteger(protocolVersion)) {
115
+ throw new AcpProtocolError(
116
+ `initialize result "protocolVersion" must be an integer, got ${String(protocolVersion)}`,
117
+ );
118
+ }
119
+ const rawAgentCapabilities = value.agentCapabilities;
120
+ const caps = isRecord(rawAgentCapabilities) ? rawAgentCapabilities : {};
121
+ return {
122
+ protocolVersion,
123
+ agentCapabilities: {
124
+ loadSession: optBool(caps, "loadSession"),
125
+ promptCapabilities: readPromptCapabilities(caps.promptCapabilities),
126
+ },
127
+ rawAgentCapabilities,
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Extract the session id from a `session/new` result (`{ sessionId }`).
133
+ * `session/load` carries the id from the request and returns only session
134
+ * metadata, so this guards the `session/new` case.
135
+ */
136
+ export function parseSessionId(value: unknown): string {
137
+ if (!isRecord(value)) {
138
+ throw new AcpProtocolError(`session result must be an object, got ${typeof value}`);
139
+ }
140
+ const sessionId = value.sessionId;
141
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
142
+ throw new AcpProtocolError(`session result "sessionId" must be a non-empty string`);
143
+ }
144
+ return sessionId;
145
+ }
146
+
147
+ /**
148
+ * Flatten an ACP `ContentBlock` (or an array of them) to plain text — the only
149
+ * projection the canonical `SessionEvent` model needs from a message/thought
150
+ * chunk. A `text` block contributes its `text`; a `resource` block with embedded
151
+ * text contributes that; every other block type (image/audio/resource_link)
152
+ * contributes nothing. Returns `null` when no text could be recovered so the
153
+ * caller can distinguish "empty text" from "no textual content at all".
154
+ */
155
+ export function contentBlockText(value: unknown): string | null {
156
+ if (typeof value === "string") return value;
157
+ if (Array.isArray(value)) {
158
+ const parts: string[] = [];
159
+ for (const item of value) {
160
+ const text = contentBlockText(item);
161
+ if (text !== null) parts.push(text);
162
+ }
163
+ return parts.length > 0 ? parts.join("") : null;
164
+ }
165
+ if (!isRecord(value)) return null;
166
+ const type = value.type;
167
+ if (type === "text") {
168
+ return typeof value.text === "string" ? value.text : null;
169
+ }
170
+ if (type === "resource") {
171
+ const resource = value.resource;
172
+ if (isRecord(resource) && typeof resource.text === "string") {
173
+ return resource.text;
174
+ }
175
+ return null;
176
+ }
177
+ return null;
178
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Unit tests for {@link spawnAcpTransport}. These spawn the test runtime's own
3
+ * `node` (never an external ACP binary), so they stay hermetic and deterministic —
4
+ * the env-gated real-harness path lives in `integration.test.ts`.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { test } from "node:test";
8
+ import { spawnAcpTransport } from "./spawn.ts";
9
+
10
+ /** A short-lived `node` invocation running the given script. */
11
+ function nodeScript(script: string) {
12
+ return spawnAcpTransport({ command: process.execPath, args: ["-e", script] });
13
+ }
14
+
15
+ test("close() suppresses the child's exit as a transport error (normal shutdown, not a fault)", async () => {
16
+ const errors: Error[] = [];
17
+ // Stay alive until stdin closes, so the only thing that ends this child is our close().
18
+ const transport = nodeScript("process.stdin.resume()");
19
+ transport.onError((error) => errors.push(error));
20
+ const exited = new Promise<void>((resolve) => transport.child.on("exit", () => resolve()));
21
+
22
+ transport.close();
23
+ await exited;
24
+ // Give any queued exit handler a turn to run.
25
+ await new Promise((resolve) => setTimeout(resolve, 0));
26
+
27
+ assert.deepEqual(errors, [], "a caller-initiated close() must not surface a spurious exit error");
28
+ });
29
+
30
+ test("a final message written without a trailing newline is flushed at EOF, not dropped", async () => {
31
+ const messages: unknown[] = [];
32
+ // Write one complete JSON message with NO trailing newline, then exit.
33
+ const transport = nodeScript('process.stdout.write(JSON.stringify({ jsonrpc: "2.0", method: "hello" }))');
34
+ transport.onError(() => {});
35
+ const received = new Promise<void>((resolve) => {
36
+ transport.onMessage((m) => {
37
+ messages.push(m);
38
+ resolve();
39
+ });
40
+ });
41
+
42
+ await received;
43
+ assert.deepEqual(messages, [{ jsonrpc: "2.0", method: "hello" }], "the unterminated final line is flushed on EOF");
44
+ transport.close();
45
+ });
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Spawn an `opencode acp` (or any ACP-speaking) harness as a subprocess and adapt
3
+ * its stdio to the {@link AcpTransport} port — ADR 0062, slice 2.
4
+ *
5
+ * This is the **only** module in the ACP backend that touches `node:child_process`:
6
+ * the JSON-RPC peer and client speak solely to the transport port, so the whole
7
+ * ingestion stack is exercisable in-memory (see `inMemoryTransportPair`) without a
8
+ * live process. `initialize` still runs against a real `opencode acp` in the
9
+ * env-gated integration test.
10
+ */
11
+ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
12
+ import { type AcpTransport, encodeMessageLine, NewlineJsonDecoder } from "./transport.ts";
13
+
14
+ export interface SpawnAcpOptions {
15
+ /** The harness executable (e.g. `"opencode"`). */
16
+ readonly command: string;
17
+ /** Its arguments (e.g. `["acp"]`). */
18
+ readonly args?: readonly string[];
19
+ /** Working directory for the harness process. */
20
+ readonly cwd?: string;
21
+ /** Extra environment for the harness (merged over `process.env`). */
22
+ readonly env?: Readonly<Record<string, string>>;
23
+ /** Where to route the harness's stderr diagnostics. Default: drained and discarded. */
24
+ readonly onStderr?: (chunk: string) => void;
25
+ }
26
+
27
+ /** An {@link AcpTransport} bound to a spawned harness, exposing the child handle. */
28
+ export interface SpawnedAcpTransport extends AcpTransport {
29
+ /** The underlying child process (for lifecycle assertions / signals). */
30
+ readonly child: ChildProcessWithoutNullStreams;
31
+ }
32
+
33
+ /**
34
+ * Spawn the harness and return a transport over its stdin/stdout with ACP's
35
+ * newline-delimited JSON framing. The child's stderr is protocol-irrelevant
36
+ * (diagnostics only): it is always piped and routed to `onStderr` when provided,
37
+ * otherwise drained and discarded (never inherited by the parent's stderr).
38
+ */
39
+ export function spawnAcpTransport(options: SpawnAcpOptions): SpawnedAcpTransport {
40
+ // Omitting `stdio` defaults every stream to "pipe", which is both what ACP's
41
+ // stdio transport needs and what makes the streams statically non-null
42
+ // (ChildProcessWithoutNullStreams). The child's stderr is protocol-irrelevant
43
+ // (diagnostics only) — routed to `onStderr`, or drained to avoid backpressure.
44
+ const child = spawn(options.command, [...(options.args ?? [])], {
45
+ cwd: options.cwd,
46
+ env: { ...process.env, ...options.env },
47
+ });
48
+ child.stdout.setEncoding("utf8");
49
+
50
+ let messageHandler: ((message: unknown) => void) | undefined;
51
+ let errorHandler: ((error: Error) => void) | undefined;
52
+ let closed = false;
53
+ const decoder = new NewlineJsonDecoder(
54
+ (message) => messageHandler?.(message),
55
+ (error) => errorHandler?.(error),
56
+ );
57
+
58
+ child.stdout.on("data", (chunk: string) => decoder.push(chunk));
59
+ // On EOF, flush any final message the harness wrote without a trailing newline
60
+ // rather than dropping it.
61
+ child.stdout.on("end", () => decoder.flush());
62
+ child.on("error", (error) => errorHandler?.(error));
63
+ child.on("exit", (code, signal) => {
64
+ // A caller-initiated close() kills the child and triggers this exit; that is a
65
+ // normal shutdown, not a transport error, so do not surface a spurious error.
66
+ if (closed) return;
67
+ errorHandler?.(new Error(`ACP harness exited (code=${String(code)}, signal=${String(signal)})`));
68
+ });
69
+ child.stderr.setEncoding("utf8");
70
+ child.stderr.on("data", (chunk: string) => options.onStderr?.(chunk));
71
+
72
+ return {
73
+ child,
74
+ send(message: unknown): void {
75
+ if (closed) return;
76
+ child.stdin.write(encodeMessageLine(message));
77
+ },
78
+ onMessage(handler: (message: unknown) => void): void {
79
+ messageHandler = handler;
80
+ },
81
+ onError(handler: (error: Error) => void): void {
82
+ errorHandler = handler;
83
+ },
84
+ close(): void {
85
+ if (closed) return;
86
+ closed = true;
87
+ child.stdin.end();
88
+ child.kill();
89
+ },
90
+ };
91
+ }
@@ -0,0 +1,82 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { encodeMessageLine, inMemoryTransportPair, NewlineJsonDecoder } from "./transport.ts";
4
+
5
+ test("NewlineJsonDecoder emits one message per complete line and ignores blanks", () => {
6
+ const messages: unknown[] = [];
7
+ const decoder = new NewlineJsonDecoder(
8
+ (m) => messages.push(m),
9
+ (e) => {
10
+ throw e;
11
+ },
12
+ );
13
+ decoder.push('{"a":1}\n\n{"b":2}\n');
14
+ assert.deepEqual(messages, [{ a: 1 }, { b: 2 }]);
15
+ });
16
+
17
+ test("NewlineJsonDecoder buffers a line split across chunks", () => {
18
+ const messages: unknown[] = [];
19
+ const decoder = new NewlineJsonDecoder(
20
+ (m) => messages.push(m),
21
+ (e) => {
22
+ throw e;
23
+ },
24
+ );
25
+ decoder.push('{"partial":');
26
+ assert.deepEqual(messages, [], "no complete line yet");
27
+ decoder.push('true}\n');
28
+ assert.deepEqual(messages, [{ partial: true }]);
29
+ });
30
+
31
+ test("NewlineJsonDecoder routes an unparseable line to onError without aborting", () => {
32
+ const messages: unknown[] = [];
33
+ const errors: Error[] = [];
34
+ const decoder = new NewlineJsonDecoder(
35
+ (m) => messages.push(m),
36
+ (e) => errors.push(e),
37
+ );
38
+ decoder.push("not json\n{\"ok\":1}\n");
39
+ assert.equal(errors.length, 1);
40
+ assert.deepEqual(messages, [{ ok: 1 }], "the stream continues past the bad line");
41
+ });
42
+
43
+ test("NewlineJsonDecoder.flush delivers a final unterminated line at EOF instead of dropping it", () => {
44
+ const messages: unknown[] = [];
45
+ const decoder = new NewlineJsonDecoder(
46
+ (m) => messages.push(m),
47
+ (e) => {
48
+ throw e;
49
+ },
50
+ );
51
+ // The second message has no trailing newline — as if the pipe hit EOF mid-line.
52
+ decoder.push('{"a":1}\n{"b":2}');
53
+ assert.deepEqual(messages, [{ a: 1 }], "push emits only the newline-terminated line");
54
+ decoder.flush();
55
+ assert.deepEqual(messages, [{ a: 1 }, { b: 2 }], "flush emits the buffered final line at EOF");
56
+ decoder.flush();
57
+ assert.deepEqual(messages, [{ a: 1 }, { b: 2 }], "flush is idempotent — the buffer is cleared");
58
+ });
59
+
60
+ test("encodeMessageLine produces exactly one newline-terminated JSON line", () => {
61
+ const line = encodeMessageLine({ jsonrpc: "2.0", id: 1 });
62
+ assert.equal(line, '{"jsonrpc":"2.0","id":1}\n');
63
+ });
64
+
65
+ test("encodeMessageLine throws on a non-JSON-serialisable message instead of emitting a bad wire line", () => {
66
+ // `JSON.stringify(undefined)` returns `undefined`; a naive template would emit
67
+ // the literal line "undefined\n", which always fails JSON.parse on the peer.
68
+ assert.throws(() => encodeMessageLine(undefined), /non-JSON-serialisable/);
69
+ });
70
+
71
+ test("inMemoryTransportPair delivers what one side sends to the other, asynchronously", async () => {
72
+ const { client, agent } = inMemoryTransportPair();
73
+ const received: unknown[] = [];
74
+ agent.onMessage((m) => received.push(m));
75
+ agent.onError((e) => {
76
+ throw e;
77
+ });
78
+ client.send({ hello: "agent" });
79
+ assert.deepEqual(received, [], "delivery is deferred to a microtask, never synchronous");
80
+ await Promise.resolve();
81
+ assert.deepEqual(received, [{ hello: "agent" }]);
82
+ });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The ACP transport seam — ADR 0062, slice 2.
3
+ *
4
+ * ACP frames JSON-RPC 2.0 as **newline-delimited JSON over stdio** (one complete
5
+ * message per line, UTF-8, no `Content-Length` headers — that is LSP/MCP framing,
6
+ * not ACP). {@link AcpConnection} speaks only to this narrow {@link AcpTransport}
7
+ * port, so the JSON-RPC peer never knows whether it is wired to a spawned
8
+ * `opencode acp` subprocess ({@link ./spawn.ts}) or, in a test, to an in-memory
9
+ * fake agent ({@link inMemoryTransportPair}). Transport is a seam, never
10
+ * re-implemented per backend (AGENTS.md: no drift surfaces).
11
+ */
12
+
13
+ /**
14
+ * A bidirectional stream of already-parsed JSON-RPC messages. Implementations own
15
+ * the newline framing and `JSON.parse`/`stringify` at the byte boundary; the peer
16
+ * above works purely in terms of JSON values.
17
+ */
18
+ export interface AcpTransport {
19
+ /** Serialise and write one JSON-RPC message toward the peer. */
20
+ send(message: unknown): void;
21
+ /**
22
+ * Register the handler for inbound messages. Called once by the connection on
23
+ * construction. A malformed line is surfaced via {@link onError} rather than
24
+ * delivered here.
25
+ */
26
+ onMessage(handler: (message: unknown) => void): void;
27
+ /** Register a handler for transport-level errors (e.g. an unparseable line). */
28
+ onError(handler: (error: Error) => void): void;
29
+ /** Close the transport and release its underlying resource. Idempotent. */
30
+ close(): void;
31
+ }
32
+
33
+ /**
34
+ * Split a byte stream into complete newline-delimited JSON messages. Handles
35
+ * chunk boundaries that fall mid-line (buffering the remainder) and ignores
36
+ * blank lines. Each decoded value is handed to `onMessage`; a line that fails to
37
+ * parse goes to `onError` and does not abort the stream.
38
+ */
39
+ export class NewlineJsonDecoder {
40
+ #buffer = "";
41
+ readonly #onMessage: (message: unknown) => void;
42
+ readonly #onError: (error: Error) => void;
43
+
44
+ constructor(onMessage: (message: unknown) => void, onError: (error: Error) => void) {
45
+ this.#onMessage = onMessage;
46
+ this.#onError = onError;
47
+ }
48
+
49
+ /** Feed a decoded string chunk; emits every complete line it now contains. */
50
+ push(chunk: string): void {
51
+ this.#buffer += chunk;
52
+ let newlineIndex = this.#buffer.indexOf("\n");
53
+ while (newlineIndex !== -1) {
54
+ const line = this.#buffer.slice(0, newlineIndex).trim();
55
+ this.#buffer = this.#buffer.slice(newlineIndex + 1);
56
+ if (line.length > 0) this.#deliver(line);
57
+ newlineIndex = this.#buffer.indexOf("\n");
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Deliver any final buffered line at stream end (EOF without a trailing
63
+ * newline). A compliant peer newline-terminates every message, but on abrupt
64
+ * process/pipe EOF a complete final message can sit unterminated in the buffer;
65
+ * flushing it surfaces the message (or a parse error) instead of silently
66
+ * dropping it. Idempotent: it clears the buffer, so a second call is a no-op.
67
+ */
68
+ flush(): void {
69
+ const line = this.#buffer.trim();
70
+ this.#buffer = "";
71
+ if (line.length > 0) this.#deliver(line);
72
+ }
73
+
74
+ #deliver(line: string): void {
75
+ let parsed: unknown;
76
+ try {
77
+ parsed = JSON.parse(line);
78
+ } catch (cause) {
79
+ this.#onError(new Error(`ACP transport received a non-JSON line: ${line.slice(0, 200)}`, { cause }));
80
+ return;
81
+ }
82
+ this.#onMessage(parsed);
83
+ }
84
+ }
85
+
86
+ /** Serialise a JSON-RPC message as a single ACP wire line (JSON + `\n`). */
87
+ export function encodeMessageLine(message: unknown): string {
88
+ const json = JSON.stringify(message);
89
+ // `JSON.stringify(undefined)` (and other non-serialisable inputs) returns
90
+ // `undefined`, which would emit the literal line "undefined\n" and always fail
91
+ // `JSON.parse` on the peer. Fail loudly at the sender instead of on the wire.
92
+ if (json === undefined) {
93
+ throw new Error("ACP transport cannot encode a non-JSON-serialisable message");
94
+ }
95
+ return `${json}\n`;
96
+ }
97
+
98
+ /**
99
+ * A pair of transports wired directly to each other in memory: what one `send`s
100
+ * the other receives (after a `queueMicrotask` hop, so delivery is asynchronous
101
+ * like a real pipe and never re-enters the sender synchronously). The reference
102
+ * substrate for driving a fake agent in tests without spawning a process.
103
+ */
104
+ export function inMemoryTransportPair(): { client: AcpTransport; agent: AcpTransport } {
105
+ const client = new InMemoryTransport();
106
+ const agent = new InMemoryTransport();
107
+ client.connect(agent);
108
+ agent.connect(client);
109
+ return { client, agent };
110
+ }
111
+
112
+ class InMemoryTransport implements AcpTransport {
113
+ #peer: InMemoryTransport | undefined;
114
+ #onMessage: ((message: unknown) => void) | undefined;
115
+ #onError: ((error: Error) => void) | undefined;
116
+ #closed = false;
117
+
118
+ connect(peer: InMemoryTransport): void {
119
+ this.#peer = peer;
120
+ }
121
+
122
+ send(message: unknown): void {
123
+ if (this.#closed) return;
124
+ // Round-trip through the wire encoding so an in-memory test exercises the same
125
+ // JSON serialisation a real pipe would.
126
+ const line = encodeMessageLine(message);
127
+ const peer = this.#peer;
128
+ queueMicrotask(() => peer?.receive(line));
129
+ }
130
+
131
+ receive(line: string): void {
132
+ if (this.#closed) return;
133
+ const handler = this.#onMessage;
134
+ if (handler === undefined) return;
135
+ const onError =
136
+ this.#onError ??
137
+ ((error: Error) => {
138
+ throw error;
139
+ });
140
+ const decoder = new NewlineJsonDecoder(handler, onError);
141
+ decoder.push(line);
142
+ }
143
+
144
+ onMessage(handler: (message: unknown) => void): void {
145
+ this.#onMessage = handler;
146
+ }
147
+
148
+ onError(handler: (error: Error) => void): void {
149
+ this.#onError = handler;
150
+ }
151
+
152
+ close(): void {
153
+ this.#closed = true;
154
+ }
155
+ }