@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,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
+ };