@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.
- package/README.md +1 -0
- 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/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/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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ACP → canonical `SessionEvent` normaliser — ADR 0062, slice 2.
|
|
3
|
+
*
|
|
4
|
+
* A **pure, per-notification classifier**: it maps one ACP `session/update`
|
|
5
|
+
* payload (the `update` object, discriminated by `sessionUpdate`) to a single
|
|
6
|
+
* {@link AcpClassifiedUpdate}. It is deliberately free of any I/O, causal-chain,
|
|
7
|
+
* or coalescing concern — those belong to {@link ./client.ts}, which owns event
|
|
8
|
+
* identity (`id`/`parentId`) and buffers streamed chunks into whole messages.
|
|
9
|
+
* Keeping the wire→model mapping a pure function is what makes the ingestion
|
|
10
|
+
* fidelity testable in isolation (see `normalize.test.ts`).
|
|
11
|
+
*
|
|
12
|
+
* ## What maps, and what does not (ADR 0062 §5 fidelity note)
|
|
13
|
+
*
|
|
14
|
+
* The three primitives the world half needs all live in one ACP notification
|
|
15
|
+
* stream:
|
|
16
|
+
*
|
|
17
|
+
* - `agent_message_chunk` / `agent_thought_chunk` / `user_message_chunk` →
|
|
18
|
+
* canonical `assistant` / `reasoning` / `user` message text.
|
|
19
|
+
* - `tool_call` → a canonical `tool-call` (the request).
|
|
20
|
+
* - `tool_call_update` at a **terminal** status (`completed`/`failed`) → a
|
|
21
|
+
* canonical `tool-result`; the tool-call lifecycle is exactly the
|
|
22
|
+
* checkpoint/effect boundary the world half (slice 4) consumes.
|
|
23
|
+
*
|
|
24
|
+
* Everything else ACP streams — `plan`, `available_commands_update`,
|
|
25
|
+
* `current_mode_update`, `config_option_update`, `session_info_update`, and the
|
|
26
|
+
* intermediate `tool_call_update`s (status `pending`/`in_progress`) — is
|
|
27
|
+
* classified `ignored`: not a canonical mind event, retained only as a reason
|
|
28
|
+
* string for observability.
|
|
29
|
+
*
|
|
30
|
+
* The **gaps** (why slice 3's native normaliser must exist) are enumerated as a
|
|
31
|
+
* first-class, testable artifact in {@link ACP_FIDELITY_GAPS} — do not let that
|
|
32
|
+
* list drift from this mapping.
|
|
33
|
+
*/
|
|
34
|
+
import { contentBlockText, isRecord } from "./protocol.js";
|
|
35
|
+
// A null-prototype map so `sessionUpdate in CHUNK_ROLE` and `CHUNK_ROLE[sessionUpdate]`
|
|
36
|
+
// only ever see the three explicit chunk types — a wire `sessionUpdate` like
|
|
37
|
+
// "toString" or "constructor" must not match an inherited Object.prototype key and
|
|
38
|
+
// classify a chunk with a bogus (function-valued) role.
|
|
39
|
+
const CHUNK_ROLE = Object.assign(Object.create(null), {
|
|
40
|
+
agent_message_chunk: "assistant",
|
|
41
|
+
agent_thought_chunk: "reasoning",
|
|
42
|
+
user_message_chunk: "user",
|
|
43
|
+
});
|
|
44
|
+
function ignored(sessionUpdate, reason) {
|
|
45
|
+
return { kind: "ignored", sessionUpdate, reason };
|
|
46
|
+
}
|
|
47
|
+
function optMessageId(update) {
|
|
48
|
+
const id = update.messageId;
|
|
49
|
+
return typeof id === "string" ? id : null;
|
|
50
|
+
}
|
|
51
|
+
function classifyChunk(update, sessionUpdate) {
|
|
52
|
+
const role = CHUNK_ROLE[sessionUpdate];
|
|
53
|
+
const text = contentBlockText(update.content);
|
|
54
|
+
if (text === null) {
|
|
55
|
+
// A non-text content block (image/audio/resource_link) carries nothing the
|
|
56
|
+
// canonical text model can represent — an intentional fidelity gap, not data.
|
|
57
|
+
return ignored(sessionUpdate, `${role} chunk had no textual content`);
|
|
58
|
+
}
|
|
59
|
+
return { kind: "message", role, messageId: optMessageId(update), text };
|
|
60
|
+
}
|
|
61
|
+
function classifyToolCall(update) {
|
|
62
|
+
const callId = update.toolCallId;
|
|
63
|
+
if (typeof callId !== "string" || callId.length === 0) {
|
|
64
|
+
return ignored("tool_call", `tool_call missing a string "toolCallId"`);
|
|
65
|
+
}
|
|
66
|
+
// ACP has no machine tool *name* distinct from the human-readable `title`
|
|
67
|
+
// (ADR 0062 §5 gap); `title` is the best available identifier, falling back to
|
|
68
|
+
// the call id when even that is absent.
|
|
69
|
+
const title = update.title;
|
|
70
|
+
const name = typeof title === "string" && title.length > 0 ? title : callId;
|
|
71
|
+
// The call arguments live in `rawInput` (an opaque JSON value) when the agent
|
|
72
|
+
// exposes them; otherwise there is nothing structured to record. Fall back to
|
|
73
|
+
// `null` (never `undefined`), mirroring the tool-result `rawOutput` path: the
|
|
74
|
+
// canonical `ToolCallEvent.args` is a JSON-serialisable value, and `undefined`
|
|
75
|
+
// is dropped by `JSON.stringify`, which would violate that shape on persist/replay.
|
|
76
|
+
const args = "rawInput" in update && update.rawInput !== undefined ? update.rawInput : null;
|
|
77
|
+
return { kind: "tool-call", callId, name, args };
|
|
78
|
+
}
|
|
79
|
+
function classifyToolCallUpdate(update) {
|
|
80
|
+
const callId = update.toolCallId;
|
|
81
|
+
if (typeof callId !== "string" || callId.length === 0) {
|
|
82
|
+
return ignored("tool_call_update", `tool_call_update missing a string "toolCallId"`);
|
|
83
|
+
}
|
|
84
|
+
const status = update.status;
|
|
85
|
+
if (status !== "completed" && status !== "failed") {
|
|
86
|
+
// pending / in_progress / unknown / status-less patch — an intermediate
|
|
87
|
+
// lifecycle beat, not yet a canonical result.
|
|
88
|
+
return ignored("tool_call_update", `tool_call_update status "${String(status)}" is not terminal`);
|
|
89
|
+
}
|
|
90
|
+
// Prefer the structured `rawOutput`; fall back to the display `content` array.
|
|
91
|
+
const result = "rawOutput" in update ? update.rawOutput : (update.content ?? null);
|
|
92
|
+
return { kind: "tool-result", callId, ok: status === "completed", result };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Classify one ACP `update` object (the `params.update` of a `session/update`
|
|
96
|
+
* notification). Pure and total — every input yields a classification, malformed
|
|
97
|
+
* or unknown ones landing in `ignored` with a diagnostic reason rather than
|
|
98
|
+
* throwing, so a single odd notification can never abort an ingestion stream.
|
|
99
|
+
*/
|
|
100
|
+
export function classifyUpdate(value) {
|
|
101
|
+
if (!isRecord(value)) {
|
|
102
|
+
return ignored("<none>", `update must be an object, got ${typeof value}`);
|
|
103
|
+
}
|
|
104
|
+
const sessionUpdate = value.sessionUpdate;
|
|
105
|
+
if (typeof sessionUpdate !== "string") {
|
|
106
|
+
return ignored("<none>", `update "sessionUpdate" must be a string, got ${typeof sessionUpdate}`);
|
|
107
|
+
}
|
|
108
|
+
if (sessionUpdate in CHUNK_ROLE) {
|
|
109
|
+
return classifyChunk(value, sessionUpdate);
|
|
110
|
+
}
|
|
111
|
+
if (sessionUpdate === "tool_call") {
|
|
112
|
+
return classifyToolCall(value);
|
|
113
|
+
}
|
|
114
|
+
if (sessionUpdate === "tool_call_update") {
|
|
115
|
+
return classifyToolCallUpdate(value);
|
|
116
|
+
}
|
|
117
|
+
return ignored(sessionUpdate, `${sessionUpdate} is not a canonical mind event`);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The enumerated resume-fidelity gaps in the ACP backend — the documented reason
|
|
121
|
+
* (ADR 0062 §5) the slice 3 native normaliser exists as a fallback path. This is
|
|
122
|
+
* a derived source of truth: `normalize.test.ts` asserts the mapping above only
|
|
123
|
+
* ever emits `assistant`/`reasoning`/`user`/`tool-call`/`tool-result`, so any
|
|
124
|
+
* canonical event ACP *cannot* produce is accounted for here.
|
|
125
|
+
*/
|
|
126
|
+
export const ACP_FIDELITY_GAPS = [
|
|
127
|
+
{
|
|
128
|
+
concept: "usage / token accounting (UsageEvent)",
|
|
129
|
+
detail: "ACP's usage_update reports context-window occupancy ({ used, size, cost }), " +
|
|
130
|
+
"not per-turn input/output token counts. It cannot reconstruct a canonical " +
|
|
131
|
+
"UsageEvent's inputTokens/outputTokens, so usage is dropped rather than " +
|
|
132
|
+
"mis-attributed; a native transcript carries the real accounting.",
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
concept: "reasoning continuation (ReasoningEvent.providerContinuation)",
|
|
136
|
+
detail: "ACP streams agent_thought_chunk text only. It has no field for a provider's " +
|
|
137
|
+
"opaque encrypted reasoning-continuation blob, so a resumed incarnation cannot " +
|
|
138
|
+
"resume the model's chain-of-thought exactly from an ACP transcript alone.",
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
concept: "model identity (UsageEvent.model)",
|
|
142
|
+
detail: "ACP does not attribute a session/update to a specific provider model id, so " +
|
|
143
|
+
"the model a turn ran on is not recoverable from the ACP stream.",
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
concept: "tool name vs. title (ToolCallEvent.name)",
|
|
147
|
+
detail: "ACP's tool_call exposes a human-readable `title` and a `kind` category but no " +
|
|
148
|
+
"stable machine tool name; the normaliser records `title` as the name, which is " +
|
|
149
|
+
"a display string, not a canonical tool identifier.",
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
concept: "compaction / truncation boundaries (CompactionEvent)",
|
|
153
|
+
detail: "ACP has no notification for a context compaction or truncation, so a replayed " +
|
|
154
|
+
"ACP history cannot mark the offset ranges a native transcript folds into a summary.",
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
concept: "explicit turn boundaries (TurnStartEvent / TurnEndEvent)",
|
|
158
|
+
detail: "ACP models a prompt's end via a state/stopReason on the session/prompt response, " +
|
|
159
|
+
"not as numbered turn-start/turn-end markers in the update stream, so canonical " +
|
|
160
|
+
"turn indices are not reconstructable from session/update notifications alone.",
|
|
161
|
+
},
|
|
162
|
+
];
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
/** The ACP protocol version this client negotiates (integer, per the spec). */
|
|
20
|
+
export declare const ACP_PROTOCOL_VERSION = 1;
|
|
21
|
+
/** Agent-handled JSON-RPC methods (client → agent requests / notifications). */
|
|
22
|
+
export declare const ACP_METHOD: {
|
|
23
|
+
readonly initialize: "initialize";
|
|
24
|
+
readonly sessionNew: "session/new";
|
|
25
|
+
readonly sessionLoad: "session/load";
|
|
26
|
+
readonly sessionPrompt: "session/prompt";
|
|
27
|
+
/** A notification — no response is expected. */
|
|
28
|
+
readonly sessionCancel: "session/cancel";
|
|
29
|
+
};
|
|
30
|
+
/** Client-handled JSON-RPC methods (agent → client requests / notifications). */
|
|
31
|
+
export declare const ACP_CLIENT_METHOD: {
|
|
32
|
+
/** Streamed session activity — a notification the agent pushes to the client. */
|
|
33
|
+
readonly sessionUpdate: "session/update";
|
|
34
|
+
/** The agent asks the client to approve a tool call. */
|
|
35
|
+
readonly requestPermission: "session/request_permission";
|
|
36
|
+
};
|
|
37
|
+
/** A JSON object with unknown-typed values — the raw wire shape before guarding. */
|
|
38
|
+
export type JsonRecord = Record<string, unknown>;
|
|
39
|
+
/** Narrow an unknown value to a plain (non-array) object. */
|
|
40
|
+
export declare function isRecord(value: unknown): value is JsonRecord;
|
|
41
|
+
/**
|
|
42
|
+
* The prompt-content capabilities an agent advertises (`agentCapabilities`
|
|
43
|
+
* sub-object). All optional booleans, defaulting to `false` when absent.
|
|
44
|
+
*/
|
|
45
|
+
export interface AcpPromptCapabilities {
|
|
46
|
+
readonly image: boolean;
|
|
47
|
+
readonly audio: boolean;
|
|
48
|
+
readonly embeddedContext: boolean;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The subset of `agentCapabilities` this slice reads from the `initialize`
|
|
52
|
+
* handshake. `loadSession` is the ADR 0062 §5 durable-resume probe; the rest is
|
|
53
|
+
* retained verbatim in {@link AcpInitializeResult.rawAgentCapabilities} for
|
|
54
|
+
* consumers that need more.
|
|
55
|
+
*/
|
|
56
|
+
export interface AcpAgentCapabilities {
|
|
57
|
+
/** Whether the agent supports `session/load` — the durable-resume signal. */
|
|
58
|
+
readonly loadSession: boolean;
|
|
59
|
+
readonly promptCapabilities: AcpPromptCapabilities;
|
|
60
|
+
}
|
|
61
|
+
/** The negotiated result of an `initialize` handshake, guarded off the wire. */
|
|
62
|
+
export interface AcpInitializeResult {
|
|
63
|
+
readonly protocolVersion: number;
|
|
64
|
+
readonly agentCapabilities: AcpAgentCapabilities;
|
|
65
|
+
/** The full, unmodified `agentCapabilities` object (opaque passthrough). */
|
|
66
|
+
readonly rawAgentCapabilities: unknown;
|
|
67
|
+
}
|
|
68
|
+
/** Raised when an ACP wire message is not the well-formed shape its method requires. */
|
|
69
|
+
export declare class AcpProtocolError extends Error {
|
|
70
|
+
constructor(message: string);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Parse an `initialize` result. The agent MAY answer with a lower
|
|
74
|
+
* `protocolVersion` than requested (down-negotiation); we surface whatever it
|
|
75
|
+
* reports and let the caller decide. A missing `agentCapabilities` is treated as
|
|
76
|
+
* "no capabilities" (every flag `false`) rather than an error, matching the ACP
|
|
77
|
+
* schema's `x-deserialize-default-on-error` default.
|
|
78
|
+
*/
|
|
79
|
+
export declare function parseInitializeResult(value: unknown): AcpInitializeResult;
|
|
80
|
+
/**
|
|
81
|
+
* Extract the session id from a `session/new` result (`{ sessionId }`).
|
|
82
|
+
* `session/load` carries the id from the request and returns only session
|
|
83
|
+
* metadata, so this guards the `session/new` case.
|
|
84
|
+
*/
|
|
85
|
+
export declare function parseSessionId(value: unknown): string;
|
|
86
|
+
/**
|
|
87
|
+
* Flatten an ACP `ContentBlock` (or an array of them) to plain text — the only
|
|
88
|
+
* projection the canonical `SessionEvent` model needs from a message/thought
|
|
89
|
+
* chunk. A `text` block contributes its `text`; a `resource` block with embedded
|
|
90
|
+
* text contributes that; every other block type (image/audio/resource_link)
|
|
91
|
+
* contributes nothing. Returns `null` when no text could be recovered so the
|
|
92
|
+
* caller can distinguish "empty text" from "no textual content at all".
|
|
93
|
+
*/
|
|
94
|
+
export declare function contentBlockText(value: unknown): string | null;
|
|
@@ -0,0 +1,136 @@
|
|
|
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
|
+
/** The ACP protocol version this client negotiates (integer, per the spec). */
|
|
20
|
+
export const ACP_PROTOCOL_VERSION = 1;
|
|
21
|
+
/** Agent-handled JSON-RPC methods (client → agent requests / notifications). */
|
|
22
|
+
export const ACP_METHOD = {
|
|
23
|
+
initialize: "initialize",
|
|
24
|
+
sessionNew: "session/new",
|
|
25
|
+
sessionLoad: "session/load",
|
|
26
|
+
sessionPrompt: "session/prompt",
|
|
27
|
+
/** A notification — no response is expected. */
|
|
28
|
+
sessionCancel: "session/cancel",
|
|
29
|
+
};
|
|
30
|
+
/** Client-handled JSON-RPC methods (agent → client requests / notifications). */
|
|
31
|
+
export const ACP_CLIENT_METHOD = {
|
|
32
|
+
/** Streamed session activity — a notification the agent pushes to the client. */
|
|
33
|
+
sessionUpdate: "session/update",
|
|
34
|
+
/** The agent asks the client to approve a tool call. */
|
|
35
|
+
requestPermission: "session/request_permission",
|
|
36
|
+
};
|
|
37
|
+
/** Narrow an unknown value to a plain (non-array) object. */
|
|
38
|
+
export function isRecord(value) {
|
|
39
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
function optBool(record, field) {
|
|
42
|
+
return record[field] === true;
|
|
43
|
+
}
|
|
44
|
+
function readPromptCapabilities(value) {
|
|
45
|
+
if (!isRecord(value)) {
|
|
46
|
+
return { image: false, audio: false, embeddedContext: false };
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
image: optBool(value, "image"),
|
|
50
|
+
audio: optBool(value, "audio"),
|
|
51
|
+
embeddedContext: optBool(value, "embeddedContext"),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Raised when an ACP wire message is not the well-formed shape its method requires. */
|
|
55
|
+
export class AcpProtocolError extends Error {
|
|
56
|
+
constructor(message) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = "AcpProtocolError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Parse an `initialize` result. The agent MAY answer with a lower
|
|
63
|
+
* `protocolVersion` than requested (down-negotiation); we surface whatever it
|
|
64
|
+
* reports and let the caller decide. A missing `agentCapabilities` is treated as
|
|
65
|
+
* "no capabilities" (every flag `false`) rather than an error, matching the ACP
|
|
66
|
+
* schema's `x-deserialize-default-on-error` default.
|
|
67
|
+
*/
|
|
68
|
+
export function parseInitializeResult(value) {
|
|
69
|
+
if (!isRecord(value)) {
|
|
70
|
+
throw new AcpProtocolError(`initialize result must be an object, got ${typeof value}`);
|
|
71
|
+
}
|
|
72
|
+
const protocolVersion = value.protocolVersion;
|
|
73
|
+
if (typeof protocolVersion !== "number" || !Number.isInteger(protocolVersion)) {
|
|
74
|
+
throw new AcpProtocolError(`initialize result "protocolVersion" must be an integer, got ${String(protocolVersion)}`);
|
|
75
|
+
}
|
|
76
|
+
const rawAgentCapabilities = value.agentCapabilities;
|
|
77
|
+
const caps = isRecord(rawAgentCapabilities) ? rawAgentCapabilities : {};
|
|
78
|
+
return {
|
|
79
|
+
protocolVersion,
|
|
80
|
+
agentCapabilities: {
|
|
81
|
+
loadSession: optBool(caps, "loadSession"),
|
|
82
|
+
promptCapabilities: readPromptCapabilities(caps.promptCapabilities),
|
|
83
|
+
},
|
|
84
|
+
rawAgentCapabilities,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Extract the session id from a `session/new` result (`{ sessionId }`).
|
|
89
|
+
* `session/load` carries the id from the request and returns only session
|
|
90
|
+
* metadata, so this guards the `session/new` case.
|
|
91
|
+
*/
|
|
92
|
+
export function parseSessionId(value) {
|
|
93
|
+
if (!isRecord(value)) {
|
|
94
|
+
throw new AcpProtocolError(`session result must be an object, got ${typeof value}`);
|
|
95
|
+
}
|
|
96
|
+
const sessionId = value.sessionId;
|
|
97
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
98
|
+
throw new AcpProtocolError(`session result "sessionId" must be a non-empty string`);
|
|
99
|
+
}
|
|
100
|
+
return sessionId;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Flatten an ACP `ContentBlock` (or an array of them) to plain text — the only
|
|
104
|
+
* projection the canonical `SessionEvent` model needs from a message/thought
|
|
105
|
+
* chunk. A `text` block contributes its `text`; a `resource` block with embedded
|
|
106
|
+
* text contributes that; every other block type (image/audio/resource_link)
|
|
107
|
+
* contributes nothing. Returns `null` when no text could be recovered so the
|
|
108
|
+
* caller can distinguish "empty text" from "no textual content at all".
|
|
109
|
+
*/
|
|
110
|
+
export function contentBlockText(value) {
|
|
111
|
+
if (typeof value === "string")
|
|
112
|
+
return value;
|
|
113
|
+
if (Array.isArray(value)) {
|
|
114
|
+
const parts = [];
|
|
115
|
+
for (const item of value) {
|
|
116
|
+
const text = contentBlockText(item);
|
|
117
|
+
if (text !== null)
|
|
118
|
+
parts.push(text);
|
|
119
|
+
}
|
|
120
|
+
return parts.length > 0 ? parts.join("") : null;
|
|
121
|
+
}
|
|
122
|
+
if (!isRecord(value))
|
|
123
|
+
return null;
|
|
124
|
+
const type = value.type;
|
|
125
|
+
if (type === "text") {
|
|
126
|
+
return typeof value.text === "string" ? value.text : null;
|
|
127
|
+
}
|
|
128
|
+
if (type === "resource") {
|
|
129
|
+
const resource = value.resource;
|
|
130
|
+
if (isRecord(resource) && typeof resource.text === "string") {
|
|
131
|
+
return resource.text;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
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 } from "node:child_process";
|
|
12
|
+
import { type AcpTransport } from "./transport.ts";
|
|
13
|
+
export interface SpawnAcpOptions {
|
|
14
|
+
/** The harness executable (e.g. `"opencode"`). */
|
|
15
|
+
readonly command: string;
|
|
16
|
+
/** Its arguments (e.g. `["acp"]`). */
|
|
17
|
+
readonly args?: readonly string[];
|
|
18
|
+
/** Working directory for the harness process. */
|
|
19
|
+
readonly cwd?: string;
|
|
20
|
+
/** Extra environment for the harness (merged over `process.env`). */
|
|
21
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
22
|
+
/** Where to route the harness's stderr diagnostics. Default: drained and discarded. */
|
|
23
|
+
readonly onStderr?: (chunk: string) => void;
|
|
24
|
+
}
|
|
25
|
+
/** An {@link AcpTransport} bound to a spawned harness, exposing the child handle. */
|
|
26
|
+
export interface SpawnedAcpTransport extends AcpTransport {
|
|
27
|
+
/** The underlying child process (for lifecycle assertions / signals). */
|
|
28
|
+
readonly child: ChildProcessWithoutNullStreams;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Spawn the harness and return a transport over its stdin/stdout with ACP's
|
|
32
|
+
* newline-delimited JSON framing. The child's stderr is protocol-irrelevant
|
|
33
|
+
* (diagnostics only): it is always piped and routed to `onStderr` when provided,
|
|
34
|
+
* otherwise drained and discarded (never inherited by the parent's stderr).
|
|
35
|
+
*/
|
|
36
|
+
export declare function spawnAcpTransport(options: SpawnAcpOptions): SpawnedAcpTransport;
|
|
@@ -0,0 +1,68 @@
|
|
|
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 { spawn } from "node:child_process";
|
|
12
|
+
import { encodeMessageLine, NewlineJsonDecoder } from "./transport.js";
|
|
13
|
+
/**
|
|
14
|
+
* Spawn the harness and return a transport over its stdin/stdout with ACP's
|
|
15
|
+
* newline-delimited JSON framing. The child's stderr is protocol-irrelevant
|
|
16
|
+
* (diagnostics only): it is always piped and routed to `onStderr` when provided,
|
|
17
|
+
* otherwise drained and discarded (never inherited by the parent's stderr).
|
|
18
|
+
*/
|
|
19
|
+
export function spawnAcpTransport(options) {
|
|
20
|
+
// Omitting `stdio` defaults every stream to "pipe", which is both what ACP's
|
|
21
|
+
// stdio transport needs and what makes the streams statically non-null
|
|
22
|
+
// (ChildProcessWithoutNullStreams). The child's stderr is protocol-irrelevant
|
|
23
|
+
// (diagnostics only) — routed to `onStderr`, or drained to avoid backpressure.
|
|
24
|
+
const child = spawn(options.command, [...(options.args ?? [])], {
|
|
25
|
+
cwd: options.cwd,
|
|
26
|
+
env: { ...process.env, ...options.env },
|
|
27
|
+
});
|
|
28
|
+
child.stdout.setEncoding("utf8");
|
|
29
|
+
let messageHandler;
|
|
30
|
+
let errorHandler;
|
|
31
|
+
let closed = false;
|
|
32
|
+
const decoder = new NewlineJsonDecoder((message) => messageHandler?.(message), (error) => errorHandler?.(error));
|
|
33
|
+
child.stdout.on("data", (chunk) => decoder.push(chunk));
|
|
34
|
+
// On EOF, flush any final message the harness wrote without a trailing newline
|
|
35
|
+
// rather than dropping it.
|
|
36
|
+
child.stdout.on("end", () => decoder.flush());
|
|
37
|
+
child.on("error", (error) => errorHandler?.(error));
|
|
38
|
+
child.on("exit", (code, signal) => {
|
|
39
|
+
// A caller-initiated close() kills the child and triggers this exit; that is a
|
|
40
|
+
// normal shutdown, not a transport error, so do not surface a spurious error.
|
|
41
|
+
if (closed)
|
|
42
|
+
return;
|
|
43
|
+
errorHandler?.(new Error(`ACP harness exited (code=${String(code)}, signal=${String(signal)})`));
|
|
44
|
+
});
|
|
45
|
+
child.stderr.setEncoding("utf8");
|
|
46
|
+
child.stderr.on("data", (chunk) => options.onStderr?.(chunk));
|
|
47
|
+
return {
|
|
48
|
+
child,
|
|
49
|
+
send(message) {
|
|
50
|
+
if (closed)
|
|
51
|
+
return;
|
|
52
|
+
child.stdin.write(encodeMessageLine(message));
|
|
53
|
+
},
|
|
54
|
+
onMessage(handler) {
|
|
55
|
+
messageHandler = handler;
|
|
56
|
+
},
|
|
57
|
+
onError(handler) {
|
|
58
|
+
errorHandler = handler;
|
|
59
|
+
},
|
|
60
|
+
close() {
|
|
61
|
+
if (closed)
|
|
62
|
+
return;
|
|
63
|
+
closed = true;
|
|
64
|
+
child.stdin.end();
|
|
65
|
+
child.kill();
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
* A bidirectional stream of already-parsed JSON-RPC messages. Implementations own
|
|
14
|
+
* the newline framing and `JSON.parse`/`stringify` at the byte boundary; the peer
|
|
15
|
+
* above works purely in terms of JSON values.
|
|
16
|
+
*/
|
|
17
|
+
export interface AcpTransport {
|
|
18
|
+
/** Serialise and write one JSON-RPC message toward the peer. */
|
|
19
|
+
send(message: unknown): void;
|
|
20
|
+
/**
|
|
21
|
+
* Register the handler for inbound messages. Called once by the connection on
|
|
22
|
+
* construction. A malformed line is surfaced via {@link onError} rather than
|
|
23
|
+
* delivered here.
|
|
24
|
+
*/
|
|
25
|
+
onMessage(handler: (message: unknown) => void): void;
|
|
26
|
+
/** Register a handler for transport-level errors (e.g. an unparseable line). */
|
|
27
|
+
onError(handler: (error: Error) => void): void;
|
|
28
|
+
/** Close the transport and release its underlying resource. Idempotent. */
|
|
29
|
+
close(): void;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Split a byte stream into complete newline-delimited JSON messages. Handles
|
|
33
|
+
* chunk boundaries that fall mid-line (buffering the remainder) and ignores
|
|
34
|
+
* blank lines. Each decoded value is handed to `onMessage`; a line that fails to
|
|
35
|
+
* parse goes to `onError` and does not abort the stream.
|
|
36
|
+
*/
|
|
37
|
+
export declare class NewlineJsonDecoder {
|
|
38
|
+
#private;
|
|
39
|
+
constructor(onMessage: (message: unknown) => void, onError: (error: Error) => void);
|
|
40
|
+
/** Feed a decoded string chunk; emits every complete line it now contains. */
|
|
41
|
+
push(chunk: string): void;
|
|
42
|
+
/**
|
|
43
|
+
* Deliver any final buffered line at stream end (EOF without a trailing
|
|
44
|
+
* newline). A compliant peer newline-terminates every message, but on abrupt
|
|
45
|
+
* process/pipe EOF a complete final message can sit unterminated in the buffer;
|
|
46
|
+
* flushing it surfaces the message (or a parse error) instead of silently
|
|
47
|
+
* dropping it. Idempotent: it clears the buffer, so a second call is a no-op.
|
|
48
|
+
*/
|
|
49
|
+
flush(): void;
|
|
50
|
+
}
|
|
51
|
+
/** Serialise a JSON-RPC message as a single ACP wire line (JSON + `\n`). */
|
|
52
|
+
export declare function encodeMessageLine(message: unknown): string;
|
|
53
|
+
/**
|
|
54
|
+
* A pair of transports wired directly to each other in memory: what one `send`s
|
|
55
|
+
* the other receives (after a `queueMicrotask` hop, so delivery is asynchronous
|
|
56
|
+
* like a real pipe and never re-enters the sender synchronously). The reference
|
|
57
|
+
* substrate for driving a fake agent in tests without spawning a process.
|
|
58
|
+
*/
|
|
59
|
+
export declare function inMemoryTransportPair(): {
|
|
60
|
+
client: AcpTransport;
|
|
61
|
+
agent: AcpTransport;
|
|
62
|
+
};
|