@jini-ai/agent-runtime 0.2.1 → 0.3.1

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 (194) hide show
  1. package/README.md +2 -2
  2. package/dist/agent-protocol/acp/session.d.ts +206 -1
  3. package/dist/agent-protocol/acp/session.d.ts.map +1 -1
  4. package/dist/agent-protocol/acp/session.js +505 -350
  5. package/dist/agent-protocol/acp/session.js.map +1 -1
  6. package/dist/agent-protocol/core/json-line-stream.d.ts +53 -0
  7. package/dist/agent-protocol/core/json-line-stream.d.ts.map +1 -1
  8. package/dist/agent-protocol/core/json-line-stream.js +307 -255
  9. package/dist/agent-protocol/core/json-line-stream.js.map +1 -1
  10. package/dist/agent-protocol/pi-rpc/events.d.ts +22 -1
  11. package/dist/agent-protocol/pi-rpc/events.d.ts.map +1 -1
  12. package/dist/agent-protocol/pi-rpc/events.js +189 -153
  13. package/dist/agent-protocol/pi-rpc/events.js.map +1 -1
  14. package/dist/agent-protocol/pi-rpc/session.d.ts +39 -0
  15. package/dist/agent-protocol/pi-rpc/session.d.ts.map +1 -1
  16. package/dist/agent-protocol/pi-rpc/session.js +143 -107
  17. package/dist/agent-protocol/pi-rpc/session.js.map +1 -1
  18. package/dist/auth.js +1 -1
  19. package/dist/capabilities.d.ts +1 -1
  20. package/dist/claude-stream.d.ts +65 -2
  21. package/dist/claude-stream.d.ts.map +1 -1
  22. package/dist/claude-stream.js +569 -442
  23. package/dist/claude-stream.js.map +1 -1
  24. package/dist/defs/aider.d.ts.map +1 -1
  25. package/dist/defs/aider.js +13 -1
  26. package/dist/defs/aider.js.map +1 -1
  27. package/dist/defs/amp.d.ts.map +1 -1
  28. package/dist/defs/amp.js +16 -1
  29. package/dist/defs/amp.js.map +1 -1
  30. package/dist/defs/amr.d.ts +2 -1
  31. package/dist/defs/amr.d.ts.map +1 -1
  32. package/dist/defs/amr.js +12 -1
  33. package/dist/defs/amr.js.map +1 -1
  34. package/dist/defs/antigravity.d.ts +25 -13
  35. package/dist/defs/antigravity.d.ts.map +1 -1
  36. package/dist/defs/antigravity.js +190 -163
  37. package/dist/defs/antigravity.js.map +1 -1
  38. package/dist/defs/claude.d.ts +7 -0
  39. package/dist/defs/claude.d.ts.map +1 -1
  40. package/dist/defs/claude.js +61 -3
  41. package/dist/defs/claude.js.map +1 -1
  42. package/dist/defs/codebuddy.d.ts.map +1 -1
  43. package/dist/defs/codebuddy.js +9 -1
  44. package/dist/defs/codebuddy.js.map +1 -1
  45. package/dist/defs/codex.d.ts +1 -0
  46. package/dist/defs/codex.d.ts.map +1 -1
  47. package/dist/defs/codex.js +34 -0
  48. package/dist/defs/codex.js.map +1 -1
  49. package/dist/defs/copilot.d.ts.map +1 -1
  50. package/dist/defs/copilot.js +15 -1
  51. package/dist/defs/copilot.js.map +1 -1
  52. package/dist/defs/cursor-agent.d.ts.map +1 -1
  53. package/dist/defs/cursor-agent.js +24 -2
  54. package/dist/defs/cursor-agent.js.map +1 -1
  55. package/dist/defs/deepseek.d.ts.map +1 -1
  56. package/dist/defs/deepseek.js +12 -0
  57. package/dist/defs/deepseek.js.map +1 -1
  58. package/dist/defs/devin.d.ts +2 -1
  59. package/dist/defs/devin.d.ts.map +1 -1
  60. package/dist/defs/devin.js +12 -7
  61. package/dist/defs/devin.js.map +1 -1
  62. package/dist/defs/grok-build.d.ts.map +1 -1
  63. package/dist/defs/grok-build.js +10 -0
  64. package/dist/defs/grok-build.js.map +1 -1
  65. package/dist/defs/hermes.d.ts +1 -0
  66. package/dist/defs/hermes.d.ts.map +1 -1
  67. package/dist/defs/hermes.js +3 -0
  68. package/dist/defs/hermes.js.map +1 -1
  69. package/dist/defs/kilo.d.ts +1 -0
  70. package/dist/defs/kilo.d.ts.map +1 -1
  71. package/dist/defs/kilo.js +3 -0
  72. package/dist/defs/kilo.js.map +1 -1
  73. package/dist/defs/kimi.d.ts +1 -0
  74. package/dist/defs/kimi.d.ts.map +1 -1
  75. package/dist/defs/kimi.js +3 -0
  76. package/dist/defs/kimi.js.map +1 -1
  77. package/dist/defs/kiro.d.ts +1 -0
  78. package/dist/defs/kiro.d.ts.map +1 -1
  79. package/dist/defs/kiro.js +3 -0
  80. package/dist/defs/kiro.js.map +1 -1
  81. package/dist/defs/opencode.d.ts +4 -0
  82. package/dist/defs/opencode.d.ts.map +1 -1
  83. package/dist/defs/opencode.js +19 -0
  84. package/dist/defs/opencode.js.map +1 -1
  85. package/dist/defs/pi.d.ts +5 -1
  86. package/dist/defs/pi.d.ts.map +1 -1
  87. package/dist/defs/pi.js +16 -4
  88. package/dist/defs/pi.js.map +1 -1
  89. package/dist/defs/qoder.d.ts +1 -0
  90. package/dist/defs/qoder.d.ts.map +1 -1
  91. package/dist/defs/qoder.js +15 -0
  92. package/dist/defs/qoder.js.map +1 -1
  93. package/dist/defs/qwen.d.ts.map +1 -1
  94. package/dist/defs/qwen.js +9 -0
  95. package/dist/defs/qwen.js.map +1 -1
  96. package/dist/defs/reasonix.d.ts +5 -0
  97. package/dist/defs/reasonix.d.ts.map +1 -1
  98. package/dist/defs/reasonix.js +19 -6
  99. package/dist/defs/reasonix.js.map +1 -1
  100. package/dist/defs/shared.d.ts +31 -1
  101. package/dist/defs/shared.d.ts.map +1 -1
  102. package/dist/defs/shared.js +35 -0
  103. package/dist/defs/shared.js.map +1 -1
  104. package/dist/defs/trae-cli.d.ts +1 -0
  105. package/dist/defs/trae-cli.d.ts.map +1 -1
  106. package/dist/defs/trae-cli.js +3 -0
  107. package/dist/defs/trae-cli.js.map +1 -1
  108. package/dist/defs/vibe.d.ts +1 -0
  109. package/dist/defs/vibe.d.ts.map +1 -1
  110. package/dist/defs/vibe.js +3 -0
  111. package/dist/defs/vibe.js.map +1 -1
  112. package/dist/detection.d.ts +1 -1
  113. package/dist/detection.d.ts.map +1 -1
  114. package/dist/detection.js +7 -2
  115. package/dist/detection.js.map +1 -1
  116. package/dist/env.js +1 -1
  117. package/dist/executables.js +1 -1
  118. package/dist/index.d.ts +2 -1
  119. package/dist/index.d.ts.map +1 -1
  120. package/dist/index.js +2 -1
  121. package/dist/index.js.map +1 -1
  122. package/dist/invocation.js +1 -1
  123. package/dist/json-event-stream.d.ts.map +1 -1
  124. package/dist/json-event-stream.js +63 -0
  125. package/dist/json-event-stream.js.map +1 -1
  126. package/dist/launch.js +1 -1
  127. package/dist/log-file.d.ts +23 -0
  128. package/dist/log-file.d.ts.map +1 -0
  129. package/dist/log-file.js +56 -0
  130. package/dist/log-file.js.map +1 -0
  131. package/dist/mcp.d.ts +1 -1
  132. package/dist/metadata.d.ts +1 -1
  133. package/dist/mmd-routes.js +1 -1
  134. package/dist/model-registry.d.ts +7 -45
  135. package/dist/model-registry.d.ts.map +1 -1
  136. package/dist/model-registry.js.map +1 -1
  137. package/dist/models.d.ts +1 -1
  138. package/dist/opencode-log.js +1 -1
  139. package/dist/paths.js +1 -1
  140. package/dist/prompt-augmenter.d.ts +1 -1
  141. package/dist/prompt-augmenter.js +1 -1
  142. package/dist/prompt-budget.d.ts +1 -1
  143. package/dist/prompt-file.js +1 -1
  144. package/dist/providers/anthropic-messages.d.ts +198 -6
  145. package/dist/providers/anthropic-messages.d.ts.map +1 -1
  146. package/dist/providers/anthropic-messages.js +318 -141
  147. package/dist/providers/anthropic-messages.js.map +1 -1
  148. package/dist/providers/azure-chat.d.ts +113 -3
  149. package/dist/providers/azure-chat.d.ts.map +1 -1
  150. package/dist/providers/azure-chat.js +114 -21
  151. package/dist/providers/azure-chat.js.map +1 -1
  152. package/dist/providers/connection-guard.d.ts +121 -1
  153. package/dist/providers/connection-guard.d.ts.map +1 -1
  154. package/dist/providers/connection-guard.js +205 -3
  155. package/dist/providers/connection-guard.js.map +1 -1
  156. package/dist/providers/connection-test.d.ts +62 -0
  157. package/dist/providers/connection-test.d.ts.map +1 -0
  158. package/dist/providers/connection-test.js +404 -0
  159. package/dist/providers/connection-test.js.map +1 -0
  160. package/dist/providers/elevenlabs.d.ts.map +1 -1
  161. package/dist/providers/elevenlabs.js +3 -2
  162. package/dist/providers/elevenlabs.js.map +1 -1
  163. package/dist/providers/google-messages.d.ts +208 -7
  164. package/dist/providers/google-messages.d.ts.map +1 -1
  165. package/dist/providers/google-messages.js +331 -105
  166. package/dist/providers/google-messages.js.map +1 -1
  167. package/dist/providers/index.d.ts +1 -0
  168. package/dist/providers/index.d.ts.map +1 -1
  169. package/dist/providers/index.js +1 -0
  170. package/dist/providers/index.js.map +1 -1
  171. package/dist/providers/model-catalog.d.ts.map +1 -1
  172. package/dist/providers/model-catalog.js +21 -0
  173. package/dist/providers/model-catalog.js.map +1 -1
  174. package/dist/providers/ollama-chat.d.ts +190 -1
  175. package/dist/providers/ollama-chat.d.ts.map +1 -1
  176. package/dist/providers/ollama-chat.js +319 -110
  177. package/dist/providers/ollama-chat.js.map +1 -1
  178. package/dist/providers/openai-chat.d.ts +222 -14
  179. package/dist/providers/openai-chat.d.ts.map +1 -1
  180. package/dist/providers/openai-chat.js +377 -128
  181. package/dist/providers/openai-chat.js.map +1 -1
  182. package/dist/providers/sse-decode.d.ts +2 -2
  183. package/dist/providers/sse-decode.js +2 -2
  184. package/dist/qoder-stream.d.ts +1 -1
  185. package/dist/qoder-stream.js +1 -1
  186. package/dist/registry.d.ts +16 -0
  187. package/dist/registry.d.ts.map +1 -1
  188. package/dist/registry.js +19 -1
  189. package/dist/registry.js.map +1 -1
  190. package/dist/resolution.js +1 -1
  191. package/dist/terminal-launch.js +1 -1
  192. package/dist/types.d.ts +181 -89
  193. package/dist/types.d.ts.map +1 -1
  194. package/package.json +6 -4
@@ -5,6 +5,14 @@
5
5
  * replies, artifact-write mirroring, DSML text suppression, and clean abort.
6
6
  * Depends on every other acp/ file and the core JSON-line stream. Consumed by
7
7
  * connection-test and server entry points (via the acp/ barrel).
8
+ *
9
+ * The `session/update` and result-routing message-kind handlers below
10
+ * (`handleSessionUpdate`, `routeResultById`, and everything they call) are
11
+ * exported as free functions over an explicit `AcpSessionState` +
12
+ * `AcpSessionEffects` pair rather than closures, specifically so they are
13
+ * directly unit-testable without spinning up a whole session. They are not
14
+ * re-exported from the package barrel — only `attachAcpSession` and the
15
+ * public types are part of this package's supported surface.
8
16
  */
9
17
  import path from 'node:path';
10
18
  import { createDsmlArtifactTextSuppressor, createToolCallTextSuppressor, } from './text-suppression.js';
@@ -16,6 +24,419 @@ import { acpRawEventShape, isAcpCompletedStatus, isAcpTerminalFailureStatus, acp
16
24
  import { findModelConfigOption, currentModelFromSessionResult, modelSelectionErrorIsRecoverable, } from './models.js';
17
25
  import { buildAcpSessionNewParams, buildPromptBlocks } from './session-params.js';
18
26
  import { noopAccountFailureClassifier } from './account-failure.js';
27
+ /** Builds a fresh {@link AcpSessionState} with every field at its initial value. */
28
+ export function createAcpSessionState() {
29
+ return {
30
+ expectedId: 1,
31
+ nextId: 2,
32
+ promptRequestId: null,
33
+ setModelRequestId: null,
34
+ sessionId: null,
35
+ durableSessionId: null,
36
+ activeModel: null,
37
+ modelConfigId: null,
38
+ emittedThinkingStart: false,
39
+ emittedTextChunk: false,
40
+ emittedVisibleTextChunk: false,
41
+ emittedTextBuffer: '',
42
+ emittedToolCall: false,
43
+ emittedConcreteToolEvent: false,
44
+ dsmlArtifactSuppressor: null,
45
+ dsmlArtifactSuppressorLastSuppressedChars: 0,
46
+ dsmlArtifactSuppressorToolCallId: null,
47
+ dsmlArtifactSuppressorArmedAfterText: false,
48
+ dsmlArtifactSuppressorSawIncrementalProse: false,
49
+ acpArtifactWriteToolCallIds: new Set(),
50
+ acpArtifactRunEventState: new Map(),
51
+ };
52
+ }
53
+ /** `typeof value === 'string' ? value : null` as a named predicate — too trivial to export on its own. */
54
+ function asOptionalString(value) {
55
+ return typeof value === 'string' ? value : null;
56
+ }
57
+ /** Fires the optional `onSessionInit` callback; kept as a one-liner so the optional-chaining call is scored against this function, not its caller. */
58
+ function notifySessionInit(effects) {
59
+ effects.onSessionInit?.();
60
+ }
61
+ /** Resolves a `session/new` response's model config id, if any. */
62
+ function resolveModelConfigId(modelConfig) {
63
+ return modelConfig?.configId ?? null;
64
+ }
65
+ /**
66
+ * Handles a JSON-RPC error frame from the agent.
67
+ *
68
+ * -32603 unexpected-id errors are cleanup noise. Expected-id model selection
69
+ * failures are recoverable; all other RPC errors are real protocol failures
70
+ * for initialize/session/new/session/prompt.
71
+ */
72
+ export function handleRpcError({ state, effects, obj, error, rpcErr, }) {
73
+ if (obj.id === state.setModelRequestId &&
74
+ modelSelectionErrorIsRecoverable(error?.code) &&
75
+ state.promptRequestId === null) {
76
+ effects.recoverFromModelSelectionError();
77
+ return;
78
+ }
79
+ if (error?.code === -32603 && obj.id !== state.expectedId) {
80
+ return;
81
+ }
82
+ const details = rpcErrorData(obj);
83
+ const promotedPayload = promotedOpenCodeSessionErrorPayload(details, rpcErr);
84
+ if (promotedPayload) {
85
+ effects.failWithPayload(promotedPayload);
86
+ return;
87
+ }
88
+ const retryable = rpcErrorRetryable(details);
89
+ effects.fail(rpcErr, {
90
+ details,
91
+ ...(retryable === undefined ? {} : { retryable }),
92
+ });
93
+ }
94
+ /** Promotes an AMR retry-status `session/update` into a structured account-failure error, if the classifier matches. Returns `true` when it did (and thus failed the session). */
95
+ export function tryPromoteAmrRetryStatus({ effects, update, }) {
96
+ const promotedPayload = promotedAmrRetryStatusPayload(update, effects.accountFailureClassifier);
97
+ if (!promotedPayload)
98
+ return false;
99
+ effects.failWithPayload(promotedPayload);
100
+ return true;
101
+ }
102
+ /** Translates an `agent_thought_chunk` update into `thinking_start` (once) / `thinking_delta` events. */
103
+ export function handleThoughtChunkUpdate({ state, effects, update, }) {
104
+ effects.emitAcpRawShapeDiagnostic(update);
105
+ const text = extractAcpUpdateText(update);
106
+ if (!text)
107
+ return;
108
+ if (!state.emittedThinkingStart) {
109
+ state.emittedThinkingStart = true;
110
+ effects.send('agent', { type: 'thinking_start' });
111
+ }
112
+ effects.send('agent', { type: 'thinking_delta', delta: text });
113
+ }
114
+ /** Strips tool-call XML from a raw message delta via the session's tool-call text suppressor, noting the suppression reason. */
115
+ export function stripToolCallText({ effects, delta }) {
116
+ const wasSuppressingToolCall = effects.toolCallTextSuppressor.isSuppressing();
117
+ const toolCallStrippedDelta = effects.toolCallTextSuppressor.strip(delta);
118
+ effects.noteToolCallTextSuppression(wasSuppressingToolCall || toolCallStrippedDelta !== delta ? 'tool_call_xml' : 'tool_call_candidate');
119
+ return toolCallStrippedDelta;
120
+ }
121
+ /**
122
+ * Whether a non-cumulative message delta should be emitted as-is (prose)
123
+ * rather than the DSML artifact suppressor's stripped output. True when this
124
+ * delta isn't itself part of an artifact echo/candidate, AND either nothing
125
+ * was actually stripped, or the suppressor was armed mid-stream (after
126
+ * visible text) and prior deltas already established this is ordinary prose
127
+ * rather than the start of another artifact echo.
128
+ */
129
+ export function shouldPreserveIncrementalProse(args) {
130
+ const { isCumulativeSnapshot, wasSuppressingArtifact, hadPendingArtifactCandidate, hasOpenArtifactCandidate, strippedDelta, toolCallStrippedDelta, dsmlArtifactSuppressorArmedAfterText, dsmlArtifactSuppressorSawIncrementalProse, } = args;
131
+ if (isCumulativeSnapshot || wasSuppressingArtifact || hadPendingArtifactCandidate || hasOpenArtifactCandidate) {
132
+ return false;
133
+ }
134
+ if (strippedDelta === toolCallStrippedDelta)
135
+ return true;
136
+ return (!dsmlArtifactSuppressorArmedAfterText &&
137
+ dsmlArtifactSuppressorSawIncrementalProse &&
138
+ !ACP_ARTIFACT_ECHO_START_RE.test(toolCallStrippedDelta));
139
+ }
140
+ /** Whether a delta passed through the DSML suppressor completely unchanged and there is no artifact suppression in progress. */
141
+ export function isPlainIncrementalDelta(args) {
142
+ const { strippedDelta, toolCallStrippedDelta, wasSuppressingArtifact, hadPendingArtifactCandidate, hasOpenArtifactCandidate, } = args;
143
+ return (strippedDelta === toolCallStrippedDelta &&
144
+ !wasSuppressingArtifact &&
145
+ !hadPendingArtifactCandidate &&
146
+ !hasOpenArtifactCandidate);
147
+ }
148
+ /** Runs a tool-call-stripped delta through the DSML artifact suppressor, emits whatever should stay visible, and arms/disarms the "saw incremental prose" and suppressor-cleared state. */
149
+ export function applyDsmlArtifactSuppression({ state, effects, suppressor, toolCallStrippedDelta, delta, isCumulativeSnapshot, }) {
150
+ const wasSuppressingArtifact = suppressor.isSuppressing();
151
+ const hadPendingArtifactCandidate = suppressor.hasPendingCandidate();
152
+ const strippedDelta = suppressor.strip(toolCallStrippedDelta);
153
+ effects.noteArtifactTextSuppression(wasSuppressingArtifact || strippedDelta !== toolCallStrippedDelta ? 'artifact_echo' : 'artifact_candidate');
154
+ const hasOpenArtifactCandidate = suppressor.isSuppressing() || suppressor.hasPendingCandidate();
155
+ const consumedArtifactText = wasSuppressingArtifact || strippedDelta !== delta;
156
+ const preserveIncrementalProse = shouldPreserveIncrementalProse({
157
+ isCumulativeSnapshot,
158
+ wasSuppressingArtifact,
159
+ hadPendingArtifactCandidate,
160
+ hasOpenArtifactCandidate,
161
+ strippedDelta,
162
+ toolCallStrippedDelta,
163
+ dsmlArtifactSuppressorArmedAfterText: state.dsmlArtifactSuppressorArmedAfterText,
164
+ dsmlArtifactSuppressorSawIncrementalProse: state.dsmlArtifactSuppressorSawIncrementalProse,
165
+ });
166
+ const outputDelta = preserveIncrementalProse ? toolCallStrippedDelta : strippedDelta;
167
+ if (outputDelta) {
168
+ effects.emitVisibleTextDelta(outputDelta);
169
+ }
170
+ if (isPlainIncrementalDelta({
171
+ strippedDelta,
172
+ toolCallStrippedDelta,
173
+ wasSuppressingArtifact,
174
+ hadPendingArtifactCandidate,
175
+ hasOpenArtifactCandidate,
176
+ })) {
177
+ state.dsmlArtifactSuppressorSawIncrementalProse = true;
178
+ }
179
+ if (consumedArtifactText && !hasOpenArtifactCandidate) {
180
+ state.dsmlArtifactSuppressor = null;
181
+ state.dsmlArtifactSuppressorToolCallId = null;
182
+ state.dsmlArtifactSuppressorArmedAfterText = false;
183
+ state.dsmlArtifactSuppressorSawIncrementalProse = false;
184
+ }
185
+ }
186
+ /** Translates an `agent_message_chunk` update into `text_delta` events, applying tool-call and DSML-artifact text suppression. */
187
+ export function handleMessageChunkUpdate({ state, effects, update, }) {
188
+ effects.emitAcpRawShapeDiagnostic(update);
189
+ const text = extractAcpUpdateText(update);
190
+ if (!text)
191
+ return;
192
+ const isCumulativeSnapshot = text.startsWith(state.emittedTextBuffer);
193
+ const delta = isCumulativeSnapshot ? text.slice(state.emittedTextBuffer.length) : text;
194
+ if (delta.length === 0)
195
+ return;
196
+ state.emittedTextChunk = true;
197
+ state.emittedTextBuffer += delta;
198
+ const toolCallStrippedDelta = stripToolCallText({ effects, delta });
199
+ if (!toolCallStrippedDelta)
200
+ return;
201
+ if (state.dsmlArtifactSuppressor) {
202
+ applyDsmlArtifactSuppression({
203
+ state,
204
+ effects,
205
+ suppressor: state.dsmlArtifactSuppressor,
206
+ toolCallStrippedDelta,
207
+ delta,
208
+ isCumulativeSnapshot,
209
+ });
210
+ }
211
+ else {
212
+ effects.emitVisibleTextDelta(toolCallStrippedDelta);
213
+ }
214
+ }
215
+ // Mirror artifact-write tool calls into the daemon's canonical
216
+ // tool_use/tool_result event shape so a run-artifacts scanner can see
217
+ // ACP file writes. Without this, an ACP agent that emits only
218
+ // text/status/thinking events (never a tool_use/tool_result pair)
219
+ // would report zero artifacts even when the run wrote artifacts.
220
+ //
221
+ // This path only feeds the NO-PROJECT fallback (project runs use the
222
+ // filesystem snapshot). Two correctness rules, both learned the hard
223
+ // way in review:
224
+ // 1. Defer emission to the TERMINAL frame and accumulate the best
225
+ // concrete path across frames — ACP often sends `locations` only
226
+ // on the completing update, and emitting on the first frame would
227
+ // lock in a wrong/empty guess that a later path can't correct.
228
+ // 2. Never fabricate an artifact extension. `isArtifactPath` is what
229
+ // decides whether a write counts; feeding it a real path lets it
230
+ // correctly EXCLUDE non-artifact edits (`config.json`, `README.md`)
231
+ // and INCLUDE real artifacts. A write that never carries a concrete
232
+ // path stays keyed on its (extension-less) toolCallId, so it is
233
+ // simply not counted rather than inflating the metric with a
234
+ // synthetic `.html` — under-counting a truly opaque write is
235
+ // acceptable; a false-positive artifact is not.
236
+ export function mirrorArtifactWriteToolEvent({ state, effects, update, toolCallId, }) {
237
+ if (!toolCallId)
238
+ return;
239
+ const isWriteCall = isAcpArtifactWriteLabel(update) || state.acpArtifactWriteToolCallIds.has(toolCallId);
240
+ if (!isWriteCall)
241
+ return;
242
+ let st = state.acpArtifactRunEventState.get(toolCallId);
243
+ if (!st) {
244
+ st = { path: null, emitted: false };
245
+ state.acpArtifactRunEventState.set(toolCallId, st);
246
+ }
247
+ if (!st.path)
248
+ st.path = acpArtifactWritePath(update);
249
+ const failed = isAcpTerminalFailureStatus(update);
250
+ const shouldEmit = !st.emitted && (failed || isAcpCompletedStatus(update));
251
+ if (!shouldEmit)
252
+ return;
253
+ st.emitted = true;
254
+ effects.send('agent', {
255
+ type: 'tool_use',
256
+ id: toolCallId,
257
+ name: 'Write',
258
+ input: { file_path: st.path ?? toolCallId },
259
+ });
260
+ effects.send('agent', { type: 'tool_result', toolUseId: toolCallId, isError: failed });
261
+ state.emittedConcreteToolEvent = true;
262
+ }
263
+ /** Arms the DSML artifact suppressor when a tool_call/tool_call_update completes an artifact write, or disarms it once its owning write call terminally fails. */
264
+ export function updateDsmlSuppressorForToolCall({ state, update, toolCallId, }) {
265
+ if (isAcpArtifactWriteUpdate(update, state.acpArtifactWriteToolCallIds)) {
266
+ state.dsmlArtifactSuppressor = createDsmlArtifactTextSuppressor();
267
+ state.dsmlArtifactSuppressorLastSuppressedChars = 0;
268
+ state.dsmlArtifactSuppressorToolCallId = toolCallId;
269
+ state.dsmlArtifactSuppressorArmedAfterText = state.emittedTextBuffer.length > 0;
270
+ state.dsmlArtifactSuppressorSawIncrementalProse = false;
271
+ if (toolCallId)
272
+ state.acpArtifactWriteToolCallIds.delete(toolCallId);
273
+ return;
274
+ }
275
+ if (!toolCallId || !isAcpTerminalFailureStatus(update))
276
+ return;
277
+ const ownsPendingWriteSuppression = toolCallId === state.dsmlArtifactSuppressorToolCallId;
278
+ const ownsPendingWriteCall = state.acpArtifactWriteToolCallIds.has(toolCallId);
279
+ state.acpArtifactWriteToolCallIds.delete(toolCallId);
280
+ if (!ownsPendingWriteSuppression && !ownsPendingWriteCall)
281
+ return;
282
+ state.dsmlArtifactSuppressor = null;
283
+ state.dsmlArtifactSuppressorToolCallId = null;
284
+ state.dsmlArtifactSuppressorArmedAfterText = false;
285
+ state.dsmlArtifactSuppressorSawIncrementalProse = false;
286
+ }
287
+ /** Translates a `tool_call` / `tool_call_update` update: tracks it as real turn output, mirrors artifact writes, and manages DSML suppressor arming. */
288
+ export function handleToolCallUpdate({ state, effects, update, }) {
289
+ // The turn did real work (a tool call / file edit), which is valid output even
290
+ // when the model emits no closing assistant text. Track it so the prompt-complete
291
+ // handler does not misreport such a turn as "no output / model unavailable".
292
+ state.emittedToolCall = true;
293
+ const toolCallId = acpToolCallId(update);
294
+ if (toolCallId && isAcpArtifactWriteLabel(update)) {
295
+ state.acpArtifactWriteToolCallIds.add(toolCallId);
296
+ }
297
+ mirrorArtifactWriteToolEvent({ state, effects, update, toolCallId });
298
+ updateDsmlSuppressorForToolCall({ state, update, toolCallId });
299
+ }
300
+ /** Top-level `session/update` dispatcher: emits a generic status for non-streaming update kinds, then routes to the matching per-kind handler. */
301
+ export function handleSessionUpdate({ state, effects, update, }) {
302
+ if (effects.modelUnavailableErrorCode && tryPromoteAmrRetryStatus({ effects, update }))
303
+ return;
304
+ if (update.sessionUpdate !== 'agent_message_chunk' && update.sessionUpdate !== 'agent_thought_chunk') {
305
+ effects.send('agent', {
306
+ type: 'status',
307
+ label: String(update.sessionUpdate || 'session_update'),
308
+ elapsedMs: Date.now() - effects.runStartedAt,
309
+ });
310
+ effects.emitAcpRawShapeDiagnostic(update);
311
+ }
312
+ if (update.sessionUpdate === 'agent_thought_chunk') {
313
+ handleThoughtChunkUpdate({ state, effects, update });
314
+ return;
315
+ }
316
+ if (update.sessionUpdate === 'agent_message_chunk') {
317
+ handleMessageChunkUpdate({ state, effects, update });
318
+ return;
319
+ }
320
+ if (update.sessionUpdate === 'tool_call' || update.sessionUpdate === 'tool_call_update') {
321
+ handleToolCallUpdate({ state, effects, update });
322
+ }
323
+ }
324
+ /** Handles the `initialize` ack: sends `session/load` (resume) or `session/new` (fresh). */
325
+ export function handleInitializeAck({ state, effects }) {
326
+ state.expectedId = state.nextId;
327
+ if (effects.resumeSessionId) {
328
+ // Resume the prior upstream session instead of creating a fresh one.
329
+ effects.writeRpc(state.nextId, 'session/load', { sessionId: effects.resumeSessionId, cwd: effects.effectiveCwd }, 'session/load');
330
+ }
331
+ else {
332
+ effects.writeRpc(state.nextId, 'session/new', buildAcpSessionNewParams(effects.effectiveCwd, effects.mcpServers ? { mcpServers: effects.mcpServers, envFormat: effects.envFormat } : { envFormat: effects.envFormat }), 'session/new');
333
+ }
334
+ state.nextId += 1;
335
+ }
336
+ /** Sends `session/set_model` (or `session/set_config_option`) when a non-default model was requested. Returns `true` when it did (caller should not send the prompt yet). */
337
+ export function triggerSetModelIfNeeded({ state, effects, }) {
338
+ if (!state.sessionId || !effects.model || effects.model === 'default')
339
+ return false;
340
+ state.setModelRequestId = state.nextId;
341
+ state.expectedId = state.nextId;
342
+ const setModelMethod = state.modelConfigId ? 'session/set_config_option' : 'session/set_model';
343
+ const setModelParams = state.modelConfigId
344
+ ? { sessionId: state.sessionId, configId: state.modelConfigId, value: effects.model }
345
+ : { sessionId: state.sessionId, modelId: effects.model };
346
+ effects.writeRpc(state.nextId, setModelMethod, setModelParams, setModelMethod);
347
+ state.nextId += 1;
348
+ return true;
349
+ }
350
+ /** Handles the `session/new` (or `session/load`) ack: records the session id, model config, and either triggers a model switch or sends the prompt. */
351
+ export function handleSessionNewAck({ state, effects, result, rawLine, }) {
352
+ state.sessionId = asOptionalString(result.sessionId);
353
+ // The durable handle for resuming this session on the next turn.
354
+ state.durableSessionId = asOptionalString(result.openCodeSessionId);
355
+ // session/new acknowledged with a session id = handshake done.
356
+ if (state.sessionId)
357
+ notifySessionInit(effects);
358
+ state.modelConfigId = resolveModelConfigId(findModelConfigOption(result.configOptions));
359
+ state.activeModel = currentModelFromSessionResult(result);
360
+ if (state.sessionId && state.activeModel) {
361
+ effects.send('agent', { type: 'status', label: 'model', model: state.activeModel });
362
+ }
363
+ if (triggerSetModelIfNeeded({ state, effects }))
364
+ return;
365
+ if (!state.sessionId) {
366
+ effects.fail(`invalid session/new response: ${rawLine}`);
367
+ return;
368
+ }
369
+ effects.sendPrompt();
370
+ }
371
+ /** Handles the `session/prompt` ack: fails a turn that reported model activity but produced no visible output, otherwise flushes and finishes cleanly. */
372
+ export function handlePromptResult({ state, effects, result, }) {
373
+ const usage = formatUsage(result.usage);
374
+ if (!state.emittedVisibleTextChunk && !state.emittedConcreteToolEvent && effects.modelUnavailableErrorCode) {
375
+ const outputTokens = usage?.output_tokens;
376
+ const hadCompletionTokens = typeof outputTokens === 'number' && outputTokens > 0;
377
+ if (hadCompletionTokens || state.emittedToolCall || state.emittedTextChunk) {
378
+ effects.fail('ACP session completed after reporting model activity, but did not produce visible assistant text, concrete tool results, or artifacts.', {
379
+ retryable: true,
380
+ details: {
381
+ kind: 'acp_no_visible_output',
382
+ output_tokens: outputTokens,
383
+ raw_tool_update_seen: state.emittedToolCall,
384
+ text_chunk_seen: state.emittedTextChunk,
385
+ },
386
+ });
387
+ }
388
+ else {
389
+ effects.fail('ACP session completed without producing any assistant text. Refresh the AMR model list, choose a supported model, and retry this run.', { forceModelUnavailable: true });
390
+ }
391
+ return;
392
+ }
393
+ effects.finishCleanPrompt(result.usage);
394
+ }
395
+ /** Whether an incoming result frame is the ack for an in-flight `session/set_model` (or `set_config_option`) request. */
396
+ export function isModelSetAckExpected({ state, effects, obj, }) {
397
+ return Boolean(state.sessionId && effects.model && effects.model !== 'default' && obj.id === state.expectedId);
398
+ }
399
+ /** Handles the `session/set_model` (or `set_config_option`) ack: records the confirmed model and sends the prompt. */
400
+ export function handleModelSetAck({ state, effects, result, }) {
401
+ // `isModelSetAckExpected` already confirmed `effects.model` is a truthy
402
+ // string before this runs; the assertion is only to carry that narrowing
403
+ // across the function boundary for the type checker (it doesn't survive
404
+ // the call), not a behavior change.
405
+ state.activeModel = currentModelFromSessionResult(result) ?? effects.model;
406
+ effects.send('agent', { type: 'status', label: 'model', model: state.activeModel });
407
+ effects.sendPrompt();
408
+ }
409
+ /** Top-level JSON-RPC result dispatcher, keyed by which request `expectedId`/`promptRequestId` is currently outstanding. */
410
+ export function routeResultById({ state, effects, obj, result, rawLine, }) {
411
+ if (obj.id !== state.expectedId || !result)
412
+ return;
413
+ if (state.expectedId === 1) {
414
+ handleInitializeAck({ state, effects });
415
+ return;
416
+ }
417
+ if (state.expectedId === 2) {
418
+ handleSessionNewAck({ state, effects, result, rawLine });
419
+ return;
420
+ }
421
+ if (state.promptRequestId !== null && obj.id === state.promptRequestId) {
422
+ handlePromptResult({ state, effects, result });
423
+ return;
424
+ }
425
+ if (isModelSetAckExpected({ state, effects, obj })) {
426
+ handleModelSetAck({ state, effects, result });
427
+ }
428
+ }
429
+ function resolveAcpSessionOptionDefaults(options) {
430
+ return {
431
+ imagePaths: options.imagePaths ?? [],
432
+ envFormat: options.envFormat ?? 'array',
433
+ clientName: options.clientName ?? 'agent-runtime',
434
+ clientVersion: options.clientVersion ?? 'runtime-adapter',
435
+ stageTimeoutMs: options.stageTimeoutMs ?? DEFAULT_STAGE_TIMEOUT_MS,
436
+ executionProfile: options.executionProfile ?? 'filesystem',
437
+ accountFailureClassifier: options.accountFailureClassifier ?? noopAccountFailureClassifier,
438
+ };
439
+ }
19
440
  /**
20
441
  * Attaches an ACP protocol session to an already-spawned child process and
21
442
  * drives the full JSON-RPC conversation from handshake to prompt completion.
@@ -41,7 +462,9 @@ import { noopAccountFailureClassifier } from './account-failure.js';
41
462
  * @returns A controller with `hasFatalError`, `getDurableSessionId`,
42
463
  * `completedSuccessfully`, and `abort` methods.
43
464
  */
44
- export function attachAcpSession({ child, prompt, cwd, model, imagePaths = [], mcpServers, envFormat = 'array', send, clientName = 'agent-runtime', clientVersion = 'runtime-adapter', stageTimeoutMs = DEFAULT_STAGE_TIMEOUT_MS, executionProfile = 'filesystem', modelUnavailableErrorCode, accountFailureClassifier = noopAccountFailureClassifier, resumeSessionId, onPermissionRequest, onCliReady, onSessionInit, }) {
465
+ export function attachAcpSession(options) {
466
+ const { child, prompt, cwd, model, mcpServers, send, modelUnavailableErrorCode, resumeSessionId, onPermissionRequest, onCliReady, onSessionInit, } = options;
467
+ const { imagePaths, envFormat, clientName, clientVersion, stageTimeoutMs, executionProfile, accountFailureClassifier, } = resolveAcpSessionOptionDefaults(options);
45
468
  const runStartedAt = Date.now();
46
469
  const effectiveCwd = path.resolve(cwd || process.cwd());
47
470
  if (!child.stdin || !child.stdout) {
@@ -49,25 +472,8 @@ export function attachAcpSession({ child, prompt, cwd, model, imagePaths = [], m
49
472
  }
50
473
  const stdin = child.stdin;
51
474
  const stdout = child.stdout;
52
- let expectedId = 1;
53
- let nextId = 2;
54
- let promptRequestId = null;
55
- let setModelRequestId = null;
56
- let sessionId = null;
57
- // The durable upstream session handle reported by the agent on session/new or
58
- // session/load (e.g. a vendor bridge's own session id). The caller stores it
59
- // per conversation to resume next turn. Distinct from `sessionId`, which is
60
- // the ACP wrapper id.
61
- let durableSessionId = null;
62
- let activeModel = null;
63
- let modelConfigId = null;
64
- let emittedThinkingStart = false;
475
+ const state = createAcpSessionState();
65
476
  let emittedFirstTokenStatus = false;
66
- let emittedTextChunk = false;
67
- let emittedVisibleTextChunk = false;
68
- let emittedToolCall = false;
69
- let emittedConcreteToolEvent = false;
70
- let emittedTextBuffer = '';
71
477
  let rawAcpShapeDiagnosticCount = 0;
72
478
  let artifactSuppressionDiagnosticCount = 0;
73
479
  let amrStderrRetryTail = '';
@@ -75,11 +481,6 @@ export function attachAcpSession({ child, prompt, cwd, model, imagePaths = [], m
75
481
  let fatal = false;
76
482
  let aborted = false;
77
483
  let stageTimer = null;
78
- let dsmlArtifactSuppressor = null;
79
- let dsmlArtifactSuppressorLastSuppressedChars = 0;
80
- let dsmlArtifactSuppressorToolCallId = null;
81
- let dsmlArtifactSuppressorArmedAfterText = false;
82
- let dsmlArtifactSuppressorSawIncrementalProse = false;
83
484
  const pendingPermissionRequestIds = new Set();
84
485
  const toolCallTextSuppressor = createToolCallTextSuppressor();
85
486
  let toolCallTextSuppressorLastSuppressedChars = 0;
@@ -95,13 +496,6 @@ export function attachAcpSession({ child, prompt, cwd, model, imagePaths = [], m
95
496
  openedBlocks: 0,
96
497
  closedBlocks: 0,
97
498
  };
98
- const acpArtifactWriteToolCallIds = new Set();
99
- // Per artifact-write tool call, accumulate the best concrete file path seen
100
- // across its frames and whether we have already mirrored it into canonical
101
- // tool_use/tool_result events. Emission is deferred to the terminal frame so
102
- // a `locations`/`rawInput` path that ACP only sends on a later update is used
103
- // for classification, instead of locking in a first-frame guess.
104
- const acpArtifactRunEventState = new Map();
105
499
  const stageWatchdogDisabled = stageTimeoutMs <= 0;
106
500
  const resetStageTimer = (label) => {
107
501
  if (stageTimer)
@@ -221,7 +615,7 @@ export function attachAcpSession({ child, prompt, cwd, model, imagePaths = [], m
221
615
  // discipline (verified unreachable by tracing every call site — see
222
616
  // source-map.md), not suppressed.
223
617
  const emitVisibleTextDelta = (delta) => {
224
- emittedVisibleTextChunk = true;
618
+ state.emittedVisibleTextChunk = true;
225
619
  if (!emittedFirstTokenStatus) {
226
620
  emittedFirstTokenStatus = true;
227
621
  send('agent', {
@@ -233,13 +627,13 @@ export function attachAcpSession({ child, prompt, cwd, model, imagePaths = [], m
233
627
  send('agent', { type: 'text_delta', delta });
234
628
  };
235
629
  const noteArtifactTextSuppression = (reason) => {
236
- if (!dsmlArtifactSuppressor)
630
+ if (!state.dsmlArtifactSuppressor)
237
631
  return;
238
- const stats = dsmlArtifactSuppressor.stats();
239
- const suppressedDelta = stats.suppressedChars - dsmlArtifactSuppressorLastSuppressedChars;
632
+ const stats = state.dsmlArtifactSuppressor.stats();
633
+ const suppressedDelta = stats.suppressedChars - state.dsmlArtifactSuppressorLastSuppressedChars;
240
634
  if (suppressedDelta <= 0)
241
635
  return;
242
- dsmlArtifactSuppressorLastSuppressedChars = stats.suppressedChars;
636
+ state.dsmlArtifactSuppressorLastSuppressedChars = stats.suppressedChars;
243
637
  artifactTextSuppressionSummary.suppressedChars += suppressedDelta;
244
638
  artifactTextSuppressionSummary.suppressedChunks = stats.suppressedChunks;
245
639
  artifactTextSuppressionSummary.openedBlocks = stats.openedBlocks;
@@ -323,10 +717,10 @@ export function attachAcpSession({ child, prompt, cwd, model, imagePaths = [], m
323
717
  });
324
718
  };
325
719
  const sendPrompt = () => {
326
- promptRequestId = nextId;
327
- expectedId = promptRequestId;
328
- writeRpc(promptRequestId, 'session/prompt', {
329
- sessionId,
720
+ state.promptRequestId = state.nextId;
721
+ state.expectedId = state.promptRequestId;
722
+ writeRpc(state.promptRequestId, 'session/prompt', {
723
+ sessionId: state.sessionId,
330
724
  prompt: buildPromptBlocks(prompt, imagePaths),
331
725
  }, 'session/prompt');
332
726
  send('agent', {
@@ -334,19 +728,21 @@ export function attachAcpSession({ child, prompt, cwd, model, imagePaths = [], m
334
728
  label: 'waiting_for_first_output',
335
729
  elapsedMs: Date.now() - runStartedAt,
336
730
  });
337
- nextId += 1;
731
+ state.nextId += 1;
338
732
  };
339
- // `finishCleanPrompt` has exactly one call site, inside the parser
340
- // callback's promptRequestId-match branch, which is itself already gated
341
- // by that same callback's own `if (aborted || finished) return;` at entry
342
- // so this function can never actually be invoked once `finished` is
343
- // true. The origin file carried its own re-entry guard here anyway;
344
- // removed per this package's coverage-driven dead-branch discipline. See
345
- // source-map.md.
733
+ // `finishCleanPrompt` has exactly one call site (`handlePromptResult`,
734
+ // called only from `routeResultById`'s promptRequestId-match branch),
735
+ // which is itself only ever reached from the parser callback's own `if
736
+ // (aborted || finished) return;` guard at entry so this function can
737
+ // never actually be invoked once `finished` is true. The origin file
738
+ // carried its own re-entry guard here anyway; removed per this package's
739
+ // coverage-driven dead-branch discipline. See source-map.md.
346
740
  const finishCleanPrompt = (usageSource) => {
347
741
  const flushedToolText = toolCallTextSuppressor.flush();
348
742
  noteToolCallTextSuppression('tool_call_xml_flush');
349
- const flushedText = flushedToolText ? (dsmlArtifactSuppressor?.strip(flushedToolText) ?? flushedToolText) : '';
743
+ const flushedText = flushedToolText
744
+ ? (state.dsmlArtifactSuppressor?.strip(flushedToolText) ?? flushedToolText)
745
+ : '';
350
746
  if (flushedText) {
351
747
  emitVisibleTextDelta(flushedText);
352
748
  }
@@ -429,11 +825,34 @@ export function attachAcpSession({ child, prompt, cwd, model, imagePaths = [], m
429
825
  }
430
826
  };
431
827
  const recoverFromModelSelectionError = () => {
432
- setModelRequestId = null;
433
- activeModel = activeModel || 'default';
434
- send('agent', { type: 'status', label: 'model', model: activeModel });
828
+ state.setModelRequestId = null;
829
+ state.activeModel = state.activeModel || 'default';
830
+ send('agent', { type: 'status', label: 'model', model: state.activeModel });
435
831
  sendPrompt();
436
832
  };
833
+ const effects = {
834
+ send,
835
+ fail,
836
+ failWithPayload,
837
+ writeRpc,
838
+ sendPrompt,
839
+ recoverFromModelSelectionError,
840
+ finishCleanPrompt,
841
+ emitVisibleTextDelta,
842
+ noteArtifactTextSuppression,
843
+ noteToolCallTextSuppression,
844
+ emitAcpRawShapeDiagnostic,
845
+ toolCallTextSuppressor,
846
+ runStartedAt,
847
+ modelUnavailableErrorCode,
848
+ accountFailureClassifier,
849
+ model,
850
+ onSessionInit,
851
+ resumeSessionId,
852
+ effectiveCwd,
853
+ mcpServers,
854
+ envFormat,
855
+ };
437
856
  const parser = createJsonLineStream((raw, rawLine) => {
438
857
  if (aborted || finished)
439
858
  return;
@@ -449,39 +868,7 @@ export function attachAcpSession({ child, prompt, cwd, model, imagePaths = [], m
449
868
  const result = asObject(obj.result);
450
869
  const rpcErr = rpcErrorMessage(obj);
451
870
  if (rpcErr) {
452
- // A late-arriving error from the agent after the session has already
453
- // finished (pipe-broken, cleanup race conditions, etc.) would be safe
454
- // to ignore here — but this whole callback is already gated by `if
455
- // (aborted || finished) return;` at its own top (line 499), and
456
- // nothing between there and here can flip `finished` to true
457
- // mid-invocation, so a second, inner check of the same condition can
458
- // never actually fire. Removed per this package's coverage-driven
459
- // dead-branch discipline (verified unreachable by tracing the
460
- // callback's own control flow — see source-map.md), not suppressed.
461
- // JSON-RPC error handling:
462
- // -32603 unexpected-id errors are cleanup noise. Expected-id model
463
- // selection failures are recoverable; all other RPC errors are real
464
- // protocol failures for initialize/session/new/session/prompt.
465
- if (obj.id === setModelRequestId &&
466
- modelSelectionErrorIsRecoverable(error?.code) &&
467
- promptRequestId === null) {
468
- recoverFromModelSelectionError();
469
- return;
470
- }
471
- if (error?.code === -32603 && obj.id !== expectedId) {
472
- return;
473
- }
474
- const details = rpcErrorData(obj);
475
- const promotedPayload = promotedOpenCodeSessionErrorPayload(details, rpcErr);
476
- if (promotedPayload) {
477
- failWithPayload(promotedPayload);
478
- return;
479
- }
480
- const retryable = rpcErrorRetryable(details);
481
- fail(rpcErr, {
482
- details,
483
- ...(retryable === undefined ? {} : { retryable }),
484
- });
871
+ handleRpcError({ state, effects, obj, error, rpcErr });
485
872
  return;
486
873
  }
487
874
  if (obj.method === 'session/request_permission') {
@@ -490,248 +877,10 @@ export function attachAcpSession({ child, prompt, cwd, model, imagePaths = [], m
490
877
  }
491
878
  const update = asObject(params?.update);
492
879
  if (obj.method === 'session/update' && update) {
493
- if (modelUnavailableErrorCode) {
494
- const promotedPayload = promotedAmrRetryStatusPayload(update, accountFailureClassifier);
495
- if (promotedPayload) {
496
- failWithPayload(promotedPayload);
497
- return;
498
- }
499
- }
500
- if (update.sessionUpdate !== 'agent_message_chunk' && update.sessionUpdate !== 'agent_thought_chunk') {
501
- send('agent', {
502
- type: 'status',
503
- label: String(update.sessionUpdate || 'session_update'),
504
- elapsedMs: Date.now() - runStartedAt,
505
- });
506
- emitAcpRawShapeDiagnostic(update);
507
- }
508
- if (update.sessionUpdate === 'agent_thought_chunk') {
509
- emitAcpRawShapeDiagnostic(update);
510
- const text = extractAcpUpdateText(update);
511
- if (text) {
512
- if (!emittedThinkingStart) {
513
- emittedThinkingStart = true;
514
- send('agent', { type: 'thinking_start' });
515
- }
516
- send('agent', { type: 'thinking_delta', delta: text });
517
- }
518
- return;
519
- }
520
- if (update.sessionUpdate === 'agent_message_chunk') {
521
- emitAcpRawShapeDiagnostic(update);
522
- const text = extractAcpUpdateText(update);
523
- if (text) {
524
- const isCumulativeSnapshot = text.startsWith(emittedTextBuffer);
525
- const delta = isCumulativeSnapshot
526
- ? text.slice(emittedTextBuffer.length)
527
- : text;
528
- if (delta.length > 0) {
529
- emittedTextChunk = true;
530
- emittedTextBuffer += delta;
531
- const wasSuppressingToolCall = toolCallTextSuppressor.isSuppressing();
532
- const toolCallStrippedDelta = toolCallTextSuppressor.strip(delta);
533
- noteToolCallTextSuppression(wasSuppressingToolCall || toolCallStrippedDelta !== delta
534
- ? 'tool_call_xml'
535
- : 'tool_call_candidate');
536
- if (!toolCallStrippedDelta) {
537
- return;
538
- }
539
- if (dsmlArtifactSuppressor) {
540
- const wasSuppressingArtifact = dsmlArtifactSuppressor.isSuppressing();
541
- const hadPendingArtifactCandidate = dsmlArtifactSuppressor.hasPendingCandidate();
542
- const strippedDelta = dsmlArtifactSuppressor.strip(toolCallStrippedDelta);
543
- noteArtifactTextSuppression(wasSuppressingArtifact || strippedDelta !== toolCallStrippedDelta
544
- ? 'artifact_echo'
545
- : 'artifact_candidate');
546
- const hasOpenArtifactCandidate = dsmlArtifactSuppressor.isSuppressing() || dsmlArtifactSuppressor.hasPendingCandidate();
547
- const consumedArtifactText = wasSuppressingArtifact || strippedDelta !== delta;
548
- const shouldPreserveIncrementalProse = !isCumulativeSnapshot &&
549
- !wasSuppressingArtifact &&
550
- !hadPendingArtifactCandidate &&
551
- !hasOpenArtifactCandidate &&
552
- (strippedDelta === toolCallStrippedDelta ||
553
- (!dsmlArtifactSuppressorArmedAfterText &&
554
- dsmlArtifactSuppressorSawIncrementalProse &&
555
- !ACP_ARTIFACT_ECHO_START_RE.test(toolCallStrippedDelta)));
556
- const outputDelta = shouldPreserveIncrementalProse ? toolCallStrippedDelta : strippedDelta;
557
- if (outputDelta) {
558
- emitVisibleTextDelta(outputDelta);
559
- }
560
- if (strippedDelta === toolCallStrippedDelta &&
561
- !wasSuppressingArtifact &&
562
- !hadPendingArtifactCandidate &&
563
- !hasOpenArtifactCandidate) {
564
- dsmlArtifactSuppressorSawIncrementalProse = true;
565
- }
566
- if (consumedArtifactText && !hasOpenArtifactCandidate) {
567
- dsmlArtifactSuppressor = null;
568
- dsmlArtifactSuppressorToolCallId = null;
569
- dsmlArtifactSuppressorArmedAfterText = false;
570
- dsmlArtifactSuppressorSawIncrementalProse = false;
571
- }
572
- }
573
- else {
574
- emitVisibleTextDelta(toolCallStrippedDelta);
575
- }
576
- }
577
- }
578
- return;
579
- }
580
- if (update.sessionUpdate === 'tool_call' ||
581
- update.sessionUpdate === 'tool_call_update') {
582
- // The turn did real work (a tool call / file edit), which is valid output even
583
- // when the model emits no closing assistant text. Track it so the prompt-complete
584
- // handler does not misreport such a turn as "no output / model unavailable".
585
- emittedToolCall = true;
586
- const toolCallId = acpToolCallId(update);
587
- if (toolCallId && isAcpArtifactWriteLabel(update)) {
588
- acpArtifactWriteToolCallIds.add(toolCallId);
589
- }
590
- // Mirror artifact-write tool calls into the daemon's canonical
591
- // tool_use/tool_result event shape so a run-artifacts scanner can see
592
- // ACP file writes. Without this, an ACP agent that emits only
593
- // text/status/thinking events (never a tool_use/tool_result pair)
594
- // would report zero artifacts even when the run wrote artifacts.
595
- //
596
- // This path only feeds the NO-PROJECT fallback (project runs use the
597
- // filesystem snapshot). Two correctness rules, both learned the hard
598
- // way in review:
599
- // 1. Defer emission to the TERMINAL frame and accumulate the best
600
- // concrete path across frames — ACP often sends `locations` only
601
- // on the completing update, and emitting on the first frame would
602
- // lock in a wrong/empty guess that a later path can't correct.
603
- // 2. Never fabricate an artifact extension. `isArtifactPath` is what
604
- // decides whether a write counts; feeding it a real path lets it
605
- // correctly EXCLUDE non-artifact edits (`config.json`, `README.md`)
606
- // and INCLUDE real artifacts. A write that never carries a concrete
607
- // path stays keyed on its (extension-less) toolCallId, so it is
608
- // simply not counted rather than inflating the metric with a
609
- // synthetic `.html` — under-counting a truly opaque write is
610
- // acceptable; a false-positive artifact is not.
611
- if (toolCallId) {
612
- const isWriteCall = isAcpArtifactWriteLabel(update) || acpArtifactWriteToolCallIds.has(toolCallId);
613
- if (isWriteCall) {
614
- let st = acpArtifactRunEventState.get(toolCallId);
615
- if (!st) {
616
- st = { path: null, emitted: false };
617
- acpArtifactRunEventState.set(toolCallId, st);
618
- }
619
- if (!st.path)
620
- st.path = acpArtifactWritePath(update);
621
- const failed = isAcpTerminalFailureStatus(update);
622
- if (!st.emitted && (failed || isAcpCompletedStatus(update))) {
623
- st.emitted = true;
624
- send('agent', {
625
- type: 'tool_use',
626
- id: toolCallId,
627
- name: 'Write',
628
- input: { file_path: st.path ?? toolCallId },
629
- });
630
- send('agent', { type: 'tool_result', toolUseId: toolCallId, isError: failed });
631
- emittedConcreteToolEvent = true;
632
- }
633
- }
634
- }
635
- if (isAcpArtifactWriteUpdate(update, acpArtifactWriteToolCallIds)) {
636
- dsmlArtifactSuppressor = createDsmlArtifactTextSuppressor();
637
- dsmlArtifactSuppressorLastSuppressedChars = 0;
638
- dsmlArtifactSuppressorToolCallId = toolCallId;
639
- dsmlArtifactSuppressorArmedAfterText = emittedTextBuffer.length > 0;
640
- dsmlArtifactSuppressorSawIncrementalProse = false;
641
- if (toolCallId)
642
- acpArtifactWriteToolCallIds.delete(toolCallId);
643
- }
644
- else if (toolCallId && isAcpTerminalFailureStatus(update)) {
645
- const ownsPendingWriteSuppression = toolCallId === dsmlArtifactSuppressorToolCallId;
646
- const ownsPendingWriteCall = acpArtifactWriteToolCallIds.has(toolCallId);
647
- acpArtifactWriteToolCallIds.delete(toolCallId);
648
- if (ownsPendingWriteSuppression || ownsPendingWriteCall) {
649
- dsmlArtifactSuppressor = null;
650
- dsmlArtifactSuppressorToolCallId = null;
651
- dsmlArtifactSuppressorArmedAfterText = false;
652
- dsmlArtifactSuppressorSawIncrementalProse = false;
653
- }
654
- }
655
- return;
656
- }
657
- return;
658
- }
659
- if (obj.id !== expectedId || !result) {
880
+ handleSessionUpdate({ state, effects, update });
660
881
  return;
661
882
  }
662
- if (expectedId === 1) {
663
- expectedId = nextId;
664
- if (resumeSessionId) {
665
- // Resume the prior upstream session instead of creating a fresh one.
666
- writeRpc(nextId, 'session/load', { sessionId: resumeSessionId, cwd: effectiveCwd }, 'session/load');
667
- }
668
- else {
669
- writeRpc(nextId, 'session/new', buildAcpSessionNewParams(effectiveCwd, mcpServers ? { mcpServers, envFormat } : { envFormat }), 'session/new');
670
- }
671
- nextId += 1;
672
- return;
673
- }
674
- if (expectedId === 2) {
675
- sessionId = typeof result.sessionId === 'string' ? result.sessionId : null;
676
- // The durable handle for resuming this session on the next turn.
677
- durableSessionId =
678
- typeof result.openCodeSessionId === 'string' ? result.openCodeSessionId : null;
679
- // session/new acknowledged with a session id = handshake done.
680
- if (sessionId)
681
- onSessionInit?.();
682
- const modelConfig = findModelConfigOption(result.configOptions);
683
- modelConfigId = modelConfig?.configId ?? null;
684
- activeModel = currentModelFromSessionResult(result);
685
- if (sessionId && activeModel) {
686
- send('agent', { type: 'status', label: 'model', model: activeModel });
687
- }
688
- if (sessionId && model && model !== 'default') {
689
- setModelRequestId = nextId;
690
- expectedId = nextId;
691
- const setModelMethod = modelConfigId ? 'session/set_config_option' : 'session/set_model';
692
- const setModelParams = modelConfigId
693
- ? { sessionId, configId: modelConfigId, value: model }
694
- : { sessionId, modelId: model };
695
- writeRpc(nextId, setModelMethod, setModelParams, setModelMethod);
696
- nextId += 1;
697
- return;
698
- }
699
- if (!sessionId) {
700
- fail(`invalid session/new response: ${rawLine}`);
701
- return;
702
- }
703
- sendPrompt();
704
- return;
705
- }
706
- if (promptRequestId !== null && obj.id === promptRequestId) {
707
- const usage = formatUsage(result.usage);
708
- if (!emittedVisibleTextChunk && !emittedConcreteToolEvent && modelUnavailableErrorCode) {
709
- const outputTokens = usage?.output_tokens;
710
- const hadCompletionTokens = typeof outputTokens === 'number' && outputTokens > 0;
711
- if (hadCompletionTokens || emittedToolCall || emittedTextChunk) {
712
- fail('ACP session completed after reporting model activity, but did not produce visible assistant text, concrete tool results, or artifacts.', {
713
- retryable: true,
714
- details: {
715
- kind: 'acp_no_visible_output',
716
- output_tokens: outputTokens,
717
- raw_tool_update_seen: emittedToolCall,
718
- text_chunk_seen: emittedTextChunk,
719
- },
720
- });
721
- }
722
- else {
723
- fail('ACP session completed without producing any assistant text. Refresh the AMR model list, choose a supported model, and retry this run.', { forceModelUnavailable: true });
724
- }
725
- return;
726
- }
727
- finishCleanPrompt(result.usage);
728
- return;
729
- }
730
- if (sessionId && model && model !== 'default' && obj.id === expectedId) {
731
- activeModel = currentModelFromSessionResult(result) ?? model;
732
- send('agent', { type: 'status', label: 'model', model: activeModel });
733
- sendPrompt();
734
- }
883
+ routeResultById({ state, effects, obj, result, rawLine });
735
884
  });
736
885
  stdout.on('data', (chunk) => parser.feed(chunk));
737
886
  child.stderr?.setEncoding('utf8');
@@ -757,6 +906,34 @@ export function attachAcpSession({ child, prompt, cwd, model, imagePaths = [], m
757
906
  clientCapabilities: { terminal: false },
758
907
  clientInfo: { name: clientName, version: clientVersion },
759
908
  }, 'initialize');
909
+ // ACP requires pending permission requests to receive a terminal
910
+ // `cancelled` response when the prompt turn is cancelled. Called before
911
+ // closing stdin, which may otherwise make the response impossible to
912
+ // deliver.
913
+ const cancelPendingPermissionRequests = () => {
914
+ for (const requestId of pendingPermissionRequestIds) {
915
+ try {
916
+ sendRpcResult(stdin, requestId, { outcome: { outcome: 'cancelled' } });
917
+ }
918
+ catch {
919
+ // Closing stdin/process signalling below remains the fallback.
920
+ }
921
+ }
922
+ pendingPermissionRequestIds.clear();
923
+ };
924
+ // Only cancel an established session; before session/new resolves there is
925
+ // no sessionId to cancel, but the caller still closes stdin below.
926
+ const sendSessionCancelIfEstablished = (activeStdin) => {
927
+ if (!state.sessionId)
928
+ return;
929
+ try {
930
+ sendRpc(activeStdin, state.nextId, 'session/cancel', { sessionId: state.sessionId });
931
+ state.nextId += 1;
932
+ }
933
+ catch {
934
+ // The caller owns process-signal fallback if the ACP transport is gone.
935
+ }
936
+ };
760
937
  return {
761
938
  /** Returns `true` when the session ended with a fatal protocol or transport error, allowing the caller to surface the failure. */
762
939
  hasFatalError() {
@@ -767,7 +944,7 @@ export function attachAcpSession({ child, prompt, cwd, model, imagePaths = [], m
767
944
  // session). Mirrors pi-rpc's getLastSessionPath().
768
945
  /** Returns the durable upstream session id (e.g. a vendor bridge's own session id) to persist for next-turn resume, or `null` when the agent did not report one. */
769
946
  getDurableSessionId() {
770
- return durableSessionId;
947
+ return state.durableSessionId;
771
948
  },
772
949
  /** Returns `true` when the prompt request resolved cleanly without a fatal error and without an abort, even if the child process later exited via SIGTERM. */
773
950
  completedSuccessfully() {
@@ -789,32 +966,10 @@ export function attachAcpSession({ child, prompt, cwd, model, imagePaths = [], m
789
966
  aborted = true;
790
967
  finished = true;
791
968
  clearStageTimer();
792
- // ACP requires pending permission requests to receive a terminal
793
- // `cancelled` response when the prompt turn is cancelled. Do this
794
- // before closing stdin, which may otherwise make the response
795
- // impossible to deliver.
796
- for (const requestId of pendingPermissionRequestIds) {
797
- try {
798
- sendRpcResult(stdin, requestId, { outcome: { outcome: 'cancelled' } });
799
- }
800
- catch {
801
- // Closing stdin/process signalling below remains the fallback.
802
- }
803
- }
804
- pendingPermissionRequestIds.clear();
969
+ cancelPendingPermissionRequests();
805
970
  if (!child.stdin || child.stdin.destroyed || child.stdin.writableEnded)
806
971
  return;
807
- // Only cancel an established session; before session/new resolves there
808
- // is no sessionId to cancel, but we must still close stdin below.
809
- if (sessionId) {
810
- try {
811
- sendRpc(child.stdin, nextId, 'session/cancel', { sessionId });
812
- nextId += 1;
813
- }
814
- catch {
815
- // The caller owns process-signal fallback if the ACP transport is gone.
816
- }
817
- }
972
+ sendSessionCancelIfEstablished(child.stdin);
818
973
  // Always close stdin so the agent receives EOF and shuts down its own
819
974
  // runtime — some ACP bridges tear down a private backing server on EOF —
820
975
  // instead of lingering (and leaking that server) until the caller's