@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.
- package/README.md +2 -1
- package/dist/demand/model.d.ts +7 -4
- package/dist/demand/model.js +22 -4
- package/dist/demand/taskdef.d.ts +13 -1
- package/dist/demand/taskdef.js +20 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/protocol/conformance/frames.js +32 -4
- package/dist/protocol/index.d.ts +1 -1
- package/dist/protocol/payloads.d.ts +44 -0
- package/dist/protocol/payloads.js +61 -7
- package/dist/session/acp/client.d.ts +109 -0
- package/dist/session/acp/client.js +254 -0
- package/dist/session/acp/index.d.ts +27 -0
- package/dist/session/acp/index.js +27 -0
- package/dist/session/acp/jsonrpc.d.ts +25 -0
- package/dist/session/acp/jsonrpc.js +148 -0
- package/dist/session/acp/normalize.d.ts +48 -0
- package/dist/session/acp/normalize.js +162 -0
- package/dist/session/acp/protocol.d.ts +94 -0
- package/dist/session/acp/protocol.js +136 -0
- package/dist/session/acp/spawn.d.ts +36 -0
- package/dist/session/acp/spawn.js +68 -0
- package/dist/session/acp/transport.d.ts +62 -0
- package/dist/session/acp/transport.js +126 -0
- package/dist/session/adapter.d.ts +135 -0
- package/dist/session/adapter.js +24 -0
- package/dist/session/backend.d.ts +43 -0
- package/dist/session/backend.js +95 -0
- package/dist/session/events.d.ts +152 -0
- package/dist/session/events.js +192 -0
- package/dist/session/index.d.ts +31 -0
- package/dist/session/index.js +5 -0
- package/dist/session/log.d.ts +107 -0
- package/dist/session/log.js +351 -0
- package/dist/session/normalizer/claude.d.ts +23 -0
- package/dist/session/normalizer/claude.js +138 -0
- package/dist/session/normalizer/copilot.d.ts +27 -0
- package/dist/session/normalizer/copilot.js +105 -0
- package/dist/session/normalizer/deepseek.d.ts +11 -0
- package/dist/session/normalizer/deepseek.js +68 -0
- package/dist/session/normalizer/index.d.ts +36 -0
- package/dist/session/normalizer/index.js +29 -0
- package/dist/session/normalizer/kimi.d.ts +10 -0
- package/dist/session/normalizer/kimi.js +80 -0
- package/dist/session/normalizer/link.d.ts +36 -0
- package/dist/session/normalizer/link.js +56 -0
- package/dist/session/normalizer/pi.d.ts +13 -0
- package/dist/session/normalizer/pi.js +61 -0
- package/dist/session/normalizer/qwen.d.ts +11 -0
- package/dist/session/normalizer/qwen.js +65 -0
- package/dist/session/normalizer/record.d.ts +21 -0
- package/dist/session/normalizer/record.js +87 -0
- package/dist/session/normalizer/types.d.ts +139 -0
- package/dist/session/normalizer/types.js +31 -0
- package/dist/session/schema.d.ts +38 -0
- package/dist/session/schema.js +74 -0
- package/dist/transcript/index.d.ts +2 -2
- package/dist/transcript/index.js +1 -1
- package/dist/transcript/schema.d.ts +23 -1
- package/dist/transcript/schema.js +34 -1
- package/dist/transcript/store.d.ts +93 -5
- package/dist/transcript/store.js +287 -6
- package/package.json +17 -1
- package/src/demand/model.test.ts +82 -4
- package/src/demand/model.ts +30 -9
- package/src/demand/taskdef.test.ts +51 -6
- package/src/demand/taskdef.ts +31 -2
- package/src/index.ts +1 -0
- package/src/protocol/conformance/frames.ts +32 -4
- package/src/protocol/index.ts +4 -0
- package/src/protocol/payloads.test.ts +31 -1
- package/src/protocol/payloads.ts +110 -7
- package/src/session/acp/client.test.ts +222 -0
- package/src/session/acp/client.ts +356 -0
- package/src/session/acp/fake-agent.ts +71 -0
- package/src/session/acp/index.ts +68 -0
- package/src/session/acp/integration.test.ts +37 -0
- package/src/session/acp/jsonrpc.test.ts +75 -0
- package/src/session/acp/jsonrpc.ts +171 -0
- package/src/session/acp/normalize.test.ts +150 -0
- package/src/session/acp/normalize.ts +204 -0
- package/src/session/acp/protocol.ts +178 -0
- package/src/session/acp/spawn.test.ts +45 -0
- package/src/session/acp/spawn.ts +91 -0
- package/src/session/acp/transport.test.ts +82 -0
- package/src/session/acp/transport.ts +155 -0
- package/src/session/adapter.ts +159 -0
- package/src/session/backend.test.ts +198 -0
- package/src/session/backend.ts +128 -0
- package/src/session/events.test.ts +168 -0
- package/src/session/events.ts +347 -0
- package/src/session/index.ts +67 -0
- package/src/session/log.test.ts +215 -0
- package/src/session/log.ts +525 -0
- package/src/session/normalizer/backend-integration.test.ts +103 -0
- package/src/session/normalizer/claude.test.ts +68 -0
- package/src/session/normalizer/claude.ts +136 -0
- package/src/session/normalizer/copilot.test.ts +59 -0
- package/src/session/normalizer/copilot.ts +133 -0
- package/src/session/normalizer/deepseek.ts +80 -0
- package/src/session/normalizer/index.ts +61 -0
- package/src/session/normalizer/kimi.ts +82 -0
- package/src/session/normalizer/link.test.ts +24 -0
- package/src/session/normalizer/link.ts +81 -0
- package/src/session/normalizer/pi.ts +75 -0
- package/src/session/normalizer/probe.test.ts +49 -0
- package/src/session/normalizer/qwen.test.ts +20 -0
- package/src/session/normalizer/qwen.ts +77 -0
- package/src/session/normalizer/record.test.ts +68 -0
- package/src/session/normalizer/record.ts +88 -0
- package/src/session/normalizer/resume.test.ts +25 -0
- package/src/session/normalizer/types.ts +152 -0
- package/src/session/normalizer/vectors.test.ts +180 -0
- package/src/session/schema.test.ts +84 -0
- package/src/session/schema.ts +78 -0
- package/src/session/test-db.ts +56 -0
- package/src/transcript/index.ts +8 -0
- package/src/transcript/schema.test.ts +31 -4
- package/src/transcript/schema.ts +36 -1
- package/src/transcript/store.ts +438 -6
- package/src/transcript/turns.test.ts +334 -0
- package/dist/blackboard/test-db.d.ts +0 -5
- package/dist/blackboard/test-db.js +0 -42
- package/dist/presence/test-db.d.ts +0 -5
- package/dist/presence/test-db.js +0 -42
- package/dist/transcript/test-db.d.ts +0 -5
- 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
|
+
}
|