@jini-ai/chat 0.5.3 → 0.5.5

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 (138) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/core/ag-ui/entry.d.ts +33 -0
  3. package/dist/core/ag-ui/entry.js +80 -0
  4. package/dist/core/agentic/chat-capabilities.d.ts +2 -4
  5. package/dist/core/agentic/index.d.ts +4 -17
  6. package/dist/core/agentic/index.js +4 -17
  7. package/dist/core/assistant-client/a2ui-action-poster.d.ts +46 -0
  8. package/dist/core/assistant-client/a2ui-action-poster.js +113 -0
  9. package/dist/core/assistant-client/ag-ui-transport.d.ts +68 -0
  10. package/dist/core/assistant-client/ag-ui-transport.js +147 -0
  11. package/dist/core/assistant-client/attachment-liveness.d.ts +37 -0
  12. package/dist/core/assistant-client/attachment-liveness.js +93 -0
  13. package/dist/core/assistant-client/durable-run-subscription.d.ts +39 -0
  14. package/dist/core/assistant-client/durable-run-subscription.js +96 -0
  15. package/dist/core/assistant-client/entry.d.ts +13 -0
  16. package/dist/core/assistant-client/entry.js +7 -0
  17. package/dist/core/assistant-client/messages.d.ts +73 -0
  18. package/dist/core/assistant-client/messages.js +68 -0
  19. package/dist/core/assistant-client/transport.d.ts +164 -0
  20. package/dist/core/assistant-client/transport.js +734 -0
  21. package/dist/core/browser/page-actions.d.ts +121 -0
  22. package/dist/core/browser/page-actions.js +113 -0
  23. package/dist/core/composer-history.d.ts +54 -0
  24. package/dist/core/composer-history.js +57 -0
  25. package/dist/core/durable-projection.d.ts +28 -0
  26. package/dist/core/durable-projection.js +71 -0
  27. package/dist/core/durable-runs/continuation.d.ts +18 -0
  28. package/dist/core/durable-runs/continuation.js +61 -0
  29. package/dist/core/durable-runs/ledger-contracts.d.ts +53 -0
  30. package/dist/core/durable-runs/ledger-contracts.js +2 -0
  31. package/dist/core/durable-runs/policies.d.ts +6 -0
  32. package/dist/core/durable-runs/policies.js +6 -0
  33. package/dist/core/durable-runs/ports.d.ts +123 -0
  34. package/dist/core/durable-runs/ports.js +2 -0
  35. package/dist/core/durable-runs/run-contracts.d.ts +17 -0
  36. package/dist/core/durable-runs/run-contracts.js +2 -0
  37. package/dist/core/durable-runs/run-lock.d.ts +4 -0
  38. package/dist/core/durable-runs/run-lock.js +4 -0
  39. package/dist/core/index.d.ts +9 -19
  40. package/dist/core/index.js +8 -19
  41. package/dist/core/messages.d.ts +2 -0
  42. package/dist/core/question-form/format.d.ts +0 -9
  43. package/dist/core/question-form/format.js +16 -2
  44. package/dist/core/question-form/normalize.js +1 -0
  45. package/dist/core/question-form/secret-answers.d.ts +38 -0
  46. package/dist/core/question-form/secret-answers.js +45 -0
  47. package/dist/core/question-form/types.d.ts +2 -0
  48. package/dist/core/question-form.d.ts +1 -0
  49. package/dist/core/question-form.js +1 -0
  50. package/dist/core/run-events/entry.d.ts +208 -16
  51. package/dist/core/run-events/entry.js +273 -39
  52. package/dist/core/transcript.d.ts +3 -2
  53. package/dist/core/transcript.js +19 -3
  54. package/dist/core/transport.d.ts +4 -0
  55. package/dist/core/user-text-redaction.d.ts +56 -0
  56. package/dist/core/user-text-redaction.js +89 -0
  57. package/dist/embed-widget/browser/page-actions.d.ts +1 -120
  58. package/dist/embed-widget/browser/page-actions.js +2 -112
  59. package/dist/embed-widget/react/embed.d.ts +1 -1
  60. package/dist/embed-widget/react/embed.js +1 -1
  61. package/dist/react/artifact-types.d.ts +9 -15
  62. package/dist/react/components/Composer.js +6 -0
  63. package/dist/react/components/MessageRow.js +24 -11
  64. package/dist/react/components/QuestionForm.d.ts +2 -1
  65. package/dist/react/components/QuestionForm.js +18 -12
  66. package/dist/react/components/QuestionsPanel.d.ts +3 -2
  67. package/dist/react/components/QuestionsPanel.js +2 -2
  68. package/dist/react/features/chat-pane/agent-tools.d.ts +1 -1
  69. package/dist/react/features/chat-pane/agent-tools.js +0 -1
  70. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +5 -2
  71. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  72. package/dist/react/features/chat-pane/components/ChatPane.js +9 -4
  73. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +2 -1
  74. package/dist/react/features/chat-pane/create-typed-answer-poster.js +33 -1
  75. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +3 -2
  76. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +7 -1
  77. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +38 -23
  78. package/dist/react/features/chat-pane/model-options.d.ts +10 -0
  79. package/dist/react/features/chat-pane/model-options.js +51 -0
  80. package/dist/react/features/chat-pane/rules.d.ts +3 -2
  81. package/dist/react/features/chat-pane/rules.js +12 -4
  82. package/dist/react/features/chat-pane/types.d.ts +30 -1
  83. package/dist/react/hooks/assistant-chats-contracts.d.ts +164 -0
  84. package/dist/react/hooks/assistant-chats-contracts.js +2 -0
  85. package/dist/react/hooks/assistant-chats.d.ts +7 -0
  86. package/dist/react/hooks/assistant-chats.js +960 -0
  87. package/dist/react/hooks/attachment-preview-cache.d.ts +1 -1
  88. package/dist/react/hooks/attachment-preview-cache.js +1 -1
  89. package/dist/react/hooks/composer-draft-cache.d.ts +2 -2
  90. package/dist/react/hooks/composer-draft-cache.js +2 -2
  91. package/dist/react/hooks/composer-history-storage.d.ts +8 -0
  92. package/dist/react/hooks/composer-history-storage.js +40 -0
  93. package/dist/react/hooks/fab-position.d.ts +90 -0
  94. package/dist/react/hooks/fab-position.js +271 -0
  95. package/dist/react/hooks/useComposer.d.ts +10 -1
  96. package/dist/react/hooks/useComposer.js +14 -2
  97. package/dist/react/hooks/useComposerHistory.hooks.d.ts +15 -0
  98. package/dist/react/hooks/useComposerHistory.hooks.js +90 -0
  99. package/dist/react/hooks/useComposerHistoryKeys.hooks.d.ts +9 -0
  100. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +90 -0
  101. package/dist/react/hooks/useConversation.d.ts +4 -1
  102. package/dist/react/hooks/useConversation.js +20 -14
  103. package/dist/react/hooks/useRunStream.js +12 -2
  104. package/dist/react/hooks/useUserTextGuard.d.ts +11 -0
  105. package/dist/react/hooks/useUserTextGuard.js +19 -0
  106. package/dist/react/index.d.ts +14 -21
  107. package/dist/react/index.js +8 -0
  108. package/dist/server/durable-runs/accept.d.ts +25 -0
  109. package/dist/server/durable-runs/accept.js +21 -0
  110. package/dist/server/durable-runs/entry.d.ts +5 -0
  111. package/dist/server/durable-runs/entry.js +4 -0
  112. package/dist/server/durable-runs/recover.d.ts +5 -0
  113. package/dist/server/durable-runs/recover.js +147 -0
  114. package/dist/server/run-finalizer/ports.d.ts +2 -16
  115. package/dist/store/legacy/sqlite/messages.d.ts +24 -18
  116. package/dist/store/legacy/sqlite/messages.js +12 -2
  117. package/dist/store/pglite/index.d.ts +2 -1
  118. package/dist/store/postgres/index.d.ts +2 -1
  119. package/dist/store/runtime/chat-expiry-sweep.d.ts +40 -0
  120. package/dist/store/runtime/chat-expiry-sweep.js +70 -0
  121. package/dist/store/runtime/chat-history-store.d.ts +20 -0
  122. package/dist/store/runtime/chat-history-store.js +50 -0
  123. package/dist/store/runtime/durable-run-store.d.ts +21 -0
  124. package/dist/store/runtime/durable-run-store.js +342 -0
  125. package/dist/store/runtime/entry.d.ts +7 -0
  126. package/dist/store/runtime/entry.js +6 -0
  127. package/dist/store/runtime/kernel.d.ts +29 -0
  128. package/dist/store/runtime/kernel.js +2 -0
  129. package/dist/store/runtime/run-guarded-chat-store.d.ts +10 -0
  130. package/dist/store/runtime/run-guarded-chat-store.js +42 -0
  131. package/dist/store/runtime/run-ledger.d.ts +15 -0
  132. package/dist/store/runtime/run-ledger.js +206 -0
  133. package/dist/store/sql/boundary.d.ts +6 -0
  134. package/dist/store/sql/boundary.js +7 -1
  135. package/dist/store/sql/store.d.ts +3 -2
  136. package/dist/store/sql/store.js +9 -2
  137. package/dist/store/sqlite/index.d.ts +3 -2
  138. package/package.json +75 -10
@@ -0,0 +1,734 @@
1
+ import { buildTranscript, latestUserPromptFromHistory } from "../transcript.js";
2
+ import * as runEvents from "../run-events/entry.js";
3
+ /** Bind transport effects once; in-flight controllers belong to this client, never the package.
4
+ * Positional run methods implement the existing ChatTransport contract. */
5
+ export function createAssistantTransportClient({ ports }, _options = {}) {
6
+ const asString = (value) => runEvents.asString({ value }, {});
7
+ const readSseFrames = (body) => runEvents.readSseFrames({ body }, {});
8
+ const readTerminalReason = (raw, wrapped) => runEvents.readTerminalReason({ raw, wrapped }, {});
9
+ const terminalReasonNotice = (reason) => runEvents.terminalReasonNotice({ reason, notice: ports.notices.toolStepLimit }, {});
10
+ const translateRunAgentPayload = (payload) => runEvents.translateRunAgentPayload({ payload }, {});
11
+ /**
12
+ * Shared HTTP implementation of `@jini-ai/chat/react`'s `ChatTransport` port (originally ADR-049).
13
+ *
14
+ * TWO run paths live here, selected per-turn by `getExecutionConfig()` (see
15
+ * `createAssistantTransport`'s own doc):
16
+ *
17
+ * 1. **Local CLI** (the original, unchanged path): binds to the real `@jini-ai/http` run surface
18
+ * `src/server/modules/assistant.ts` mounts — `POST /api/runs` to start, `GET
19
+ * /api/runs/:runId/events` (native `EventSource`) to stream, `GET /api/runs/:runId` for status,
20
+ * `POST /api/runs/:runId/cancel` to stop. Every SSE frame's `data` is a full `@jini-ai/protocol`
21
+ * `RunProtocolEvent` (`{kind, payload, ...}`), not chat-core's own `AgentEvent` — chat-core's own
22
+ * module doc is explicit that this reduction is a transport concern ("a host's transport adapter
23
+ * is responsible for reducing wire deltas... into the persisted/renderable AgentEvent items"), so
24
+ * `translateRunAgentPayload` below does that translation.
25
+ * 2. **API · BYOK** (2026-08-04, ADR-049's picker): binds to `src/server/modules/assistant-byok.ts`'s
26
+ * `POST /api/admin/v1/assistant/byok-turn` — one request/response holding the WHOLE turn open,
27
+ * no separate `EventSource`/reattach/cancel-by-runId (see that route's own header for why, and
28
+ * what it costs). Its SSE frames carry the SAME `payload.type` vocabulary
29
+ * (`status`/`text_delta`/`tool_use`/`tool_result`/`usage`/`error`) `translateRunAgentPayload`
30
+ * already parses for path 1 — deliberately, so this path reuses that exact function rather than
31
+ * duplicating the translation switch.
32
+ *
33
+ * `text_delta`/`thinking_delta` are forwarded as their own small `AgentEvent` per delta (not
34
+ * accumulated here) — chat-core's own `ChatMessage.events` array is what concatenates them into one
35
+ * growing message, so accumulating twice would double the text.
36
+ */
37
+ const RUNS_URL = ports.runsUrl;
38
+ const BYOK_TURN_URL = ports.byokTurnUrl;
39
+ /** Distinguishes a BYOK-run id (client-minted, no server-side run record) from a daemon-run id
40
+ * (server-minted, reattachable) wherever a bare `runId` string is all a `ChatTransport` method
41
+ * receives — see `stopRun`/`fetchRunStatus`/`reattachRun` below for why the distinction matters. */
42
+ const BYOK_RUN_ID_PREFIX = ports.byokRunIdPrefix;
43
+ /**
44
+ * How many trailing messages of a conversation go to the agent.
45
+ *
46
+ * A cap rather than none: each turn cold-boots a fresh subprocess and pays for its whole input, so
47
+ * an uncapped transcript makes every message in a long chat more expensive than the last. 40 is
48
+ * roughly 20 exchanges — beyond what a working session usually needs to stay coherent, and far
49
+ * short of where input cost starts to dominate. `buildTranscript` separately truncates any single
50
+ * oversized message, so this bounds the number of turns, not their size.
51
+ */
52
+ const MAX_TRANSCRIPT_TURNS = 40;
53
+ // The daemon-stream translation (`translateRunAgentPayload`, the terminal notices, `translateRunFrame`)
54
+ // lives in `@jini-ai/chat/core/run-events`, extracted from the host's 2026-09-27 contract, so the API's
55
+ // run finalizer saves exactly what this transport would have. Re-exported here so existing importers
56
+ // and tests keep their paths.
57
+ /**
58
+ * The prompt for one run: the whole conversation so far, not just the newest message.
59
+ *
60
+ * This used to send only the latest user turn, which is why the assistant appeared to have no
61
+ * memory — ask it something, then ask a follow-up, and the second run had never seen the first.
62
+ * That looked like a missing capability and was not: `ChatPane` hands the full history to every
63
+ * `startRun`, and it was being discarded here, in the browser, before the request was even built.
64
+ * Nothing server-side had to change.
65
+ *
66
+ * `buildTranscript` rather than a hand-rolled join, because flattening a transcript has more edges
67
+ * than it first appears: it truncates any single oversized message, escapes `## user`/`## assistant`
68
+ * inside message bodies so a user cannot forge a turn boundary by pasting one, summarizes persisted
69
+ * artifacts instead of replaying them, and prepends a warning when prior-run telemetry shows the
70
+ * context was already large. Every admin turn cold-boots a fresh CLI subprocess, so the transcript
71
+ * IS the memory — and the same property makes an unbounded one expensive.
72
+ *
73
+ * Bounded deliberately: each run is billed, and an unbounded transcript grows the input cost of
74
+ * every subsequent turn in a conversation that has no natural end. {@link MAX_TRANSCRIPT_TURNS}
75
+ * keeps a long-running chat from silently becoming the most expensive thing in the product.
76
+ */
77
+ function runPrompt(history) {
78
+ // `historyForTranscript` first, THEN the cap: dropping failed rows before slicing means the 40
79
+ // turns that survive are 40 real ones, not 40 slots some of which are rows the agent never wrote.
80
+ const recent = historyForTranscript(history).slice(-MAX_TRANSCRIPT_TURNS);
81
+ return buildTranscript({ history: recent });
82
+ }
83
+ /**
84
+ * Whether an assistant message represents a turn the agent actually answered — the single
85
+ * definition both {@link historyForTranscript} and {@link undeliveredUserPrompt} key off.
86
+ *
87
+ * `undefined` counts as answered: rows written before `ai_chat_messages.run_status` existed carry
88
+ * no status at all, and treating those as unanswered would re-send the whole history of every
89
+ * legacy conversation on its next turn. Every non-`succeeded` status counts as unanswered —
90
+ * `failed` and `canceled` obviously, and `queued`/`running` because a run killed mid-flight (a
91
+ * daemon respawn, see `daemon-supervisor.ts`) never reaches a terminal status at all and leaves
92
+ * exactly such a row behind.
93
+ *
94
+ * Do not "simplify" this away by deleting the failed rows at the source instead. That was the first
95
+ * shape proposed for this fix and it is the wrong trade: `run_status='failed'` was ALREADY the
96
+ * explicit failed state, and the only thing missing was somewhere that skips it. The row's
97
+ * `events_json` is the sole durable record of why a run died (exit code, signal, the CLI's own
98
+ * stderr) once the daemon's in-memory event log is gone, and the pane renders it on reload — so
99
+ * dropping the row trades a history bug for a forensics hole. The skip belongs here, on the read
100
+ * side. See {@link historyForTranscript} and `assistant-chats.ts`'s `persistableMessages`.
101
+ */
102
+ function isAnsweredAssistantTurn(message) {
103
+ return message.runStatus === undefined || message.runStatus === "succeeded";
104
+ }
105
+ /**
106
+ * The history a transcript may be built from: every user turn, and only those assistant turns the
107
+ * agent actually answered.
108
+ *
109
+ * Defect 3 of the 2026-09-11 chat-lifecycle repair. `ai_chat_messages` in
110
+ * the investigated host's `chat.db` holds assistant rows with `run_status='failed'` and
111
+ * `length(content)=0` — the durable trace of a run that died before writing a token. Replayed
112
+ * through `buildTranscript` they become a `## assistant` block with nothing under it: the next run
113
+ * is told the agent replied when what actually happened is that it died, and a failure the user can
114
+ * see in the pane is laundered into an empty answer in the model's context.
115
+ *
116
+ * The rows themselves are deliberately NOT deleted, and this is the fix instead: their
117
+ * `events_json` carries the only surviving record of why the run failed (exit code, signal, the
118
+ * CLI's own stderr), the daemon's event log is in-memory and gone, and the pane renders that notice
119
+ * on reload. Partial output from a failed run stays stored as partial output for the same reason —
120
+ * it is visible to the user, and it is not the agent's answer, so it does not go back into a prompt.
121
+ *
122
+ * @param history - The pane's transcript, oldest first.
123
+ * @returns A new array; the input is never mutated.
124
+ * @complexity O(n) in history length, one pass, no allocation per message.
125
+ */
126
+ function historyForTranscript(history) {
127
+ return history.filter((message) => message.role !== "assistant" || isAnsweredAssistantTurn(message));
128
+ }
129
+ /**
130
+ * Prepended when more than one user turn is being sent at once, so the agent is not told the user
131
+ * typed all of it just now. Deliberately absent from the single-turn case — a note on every
132
+ * ordinary message would be a standing lie in the prompt of every turn in every conversation.
133
+ */
134
+ const UNDELIVERED_TURNS_NOTE = "[Some of the messages below never reached you: the run that should have answered them failed before it could. Treat them as the user's own words, in order, and answer all of them.]";
135
+ /**
136
+ * The user text a run must carry when the agent carries its own conversation memory: every user
137
+ * turn since the last one the agent actually answered, not only the newest.
138
+ *
139
+ * Defect 2 of the 2026-09-11 chat-lifecycle repair, and the half of it that was actually lossy. In
140
+ * the investigated host's `chat.db` conversation `9289701c-…`, position 5's user text is present in
141
+ * `ai_chat_messages` and absent from every agent-session transcript: the run meant to answer it
142
+ * failed 140 ms after start, so no CLI ever read it — and because
143
+ * {@link resolveLocalCliPrompt} sends only the NEWEST user turn to a resume-capable agent, no later
144
+ * turn ever carried it either. The message was durable the whole time and still lost, permanently,
145
+ * with nothing anywhere saying so.
146
+ *
147
+ * Re-delivering costs a duplicate whenever the CLI did read the prompt before dying, which the
148
+ * agent can reconcile from its own session. Not re-delivering costs the user a message with no
149
+ * trace. The two are not symmetric, so this errs toward the duplicate.
150
+ *
151
+ * @param history - The pane's transcript, oldest first.
152
+ * @returns The prompt text — the bare newest turn in the ordinary case (the previous turn was
153
+ * answered), or the unanswered turns joined oldest-first behind {@link UNDELIVERED_TURNS_NOTE}.
154
+ * Falls back to the newest user turn when the history ends in an answered assistant turn and
155
+ * there is therefore nothing pending.
156
+ * @complexity O(n) in history length — one forward scan for the boundary plus one slice/filter over
157
+ * the tail. A backwards scan with an early exit would be O(1) in practice but needs `break`, and
158
+ * `n` here is bounded by one conversation's transcript; this is not a hot path (once per turn).
159
+ * The result is capped at {@link MAX_TRANSCRIPT_TURNS} for the pathological case of a
160
+ * conversation whose every run has failed.
161
+ */
162
+ function undeliveredUserPrompt(history) {
163
+ // `Array.prototype.findLastIndex` would say this in one call, but it is ES2023 and this app
164
+ // targets ES2022 (`apps/admin/tsconfig.json`), so the index is accumulated instead.
165
+ let lastAnsweredIndex = -1;
166
+ history.forEach((message, position) => {
167
+ if (message.role === "assistant" && isAnsweredAssistantTurn(message))
168
+ lastAnsweredIndex = position;
169
+ });
170
+ const pending = history
171
+ .slice(lastAnsweredIndex + 1)
172
+ .filter((message) => message.role === "user")
173
+ .slice(-MAX_TRANSCRIPT_TURNS);
174
+ if (pending.length === 0)
175
+ return latestUserPromptFromHistory({ history: history });
176
+ const newest = pending[pending.length - 1];
177
+ const newestText = newest.content.trim();
178
+ const newestAttachmentRefs = JSON.stringify((newest.attachments ?? []).map((attachment) => attachment.path));
179
+ // Collapse resends of the newest text with matching attachment refs: a retry is one request.
180
+ const distinct = pending.filter((message, index) => index === pending.length - 1
181
+ || message.content.trim() !== newestText
182
+ || JSON.stringify((message.attachments ?? []).map((attachment) => attachment.path)) !== newestAttachmentRefs)
183
+ .map((message) => message.content);
184
+ if (distinct.length === 1)
185
+ return distinct[0];
186
+ return `${UNDELIVERED_TURNS_NOTE}\n\n${distinct.join("\n\n")}`;
187
+ }
188
+ /** `@jini-ai/protocol`'s `RunState` -> chat-core's flat `RunStatus` union (different spelling: `cancelled` vs `canceled`, `pending` vs `queued`). */
189
+ function toChatCoreRunStatus(state) {
190
+ switch (state) {
191
+ case "pending":
192
+ return "queued";
193
+ case "running":
194
+ return "running";
195
+ case "succeeded":
196
+ return "succeeded";
197
+ case "failed":
198
+ return "failed";
199
+ case "cancelled":
200
+ return "canceled";
201
+ default:
202
+ return undefined;
203
+ }
204
+ }
205
+ /** The durable message owns recovery and terminal state; this subscription follows replacement
206
+ * attempts without writing a browser-inferred failure into history. */
207
+ function subscribeToRun(runId, handlers, signal) {
208
+ ports.followRun({ runId, handlers, ...(signal === undefined ? {} : { signal }) }, {});
209
+ }
210
+ /** Client-minted, not server-minted — see module doc's path-2 section: a BYOK run has no server-side
211
+ * run record to name it. Prefixed so `stopRun`/`fetchRunStatus`/`reattachRun` below can tell a
212
+ * BYOK-run id apart from a daemon-run id without any other context. */
213
+ function mintByokRunId() { return `${BYOK_RUN_ID_PREFIX}${ports.mintId({}, {})}`; }
214
+ /** In-flight BYOK turns' abort controllers, keyed by the client-minted runId — the ONLY way
215
+ * `stopRun` can cancel a BYOK turn: unlike the daemon path, there is no server-side run record to
216
+ * `POST .../cancel` against, so cancellation has to reach back into THIS tab's own in-flight
217
+ * `fetch`. Entries are removed as soon as a turn settles (normally, on error, or on abort) so a
218
+ * stale id can never resurrect a finished controller. */
219
+ const byokAbortControllers = new Map();
220
+ /**
221
+ * Dispatches one parsed BYOK SSE frame to the right `RunHandlers` call — the `"agent"`/`"error"`/
222
+ * `"end"` handling pulled out of `startByokRun`'s stream-consumer IIFE (2026-08-06, complexity
223
+ * pass), where it sat four levels of nesting deep (async IIFE > `try` > `for await` > `if`/
224
+ * `else if`, with a further nested `if` inside the `"agent"` and `"end"` cases). At module scope it
225
+ * is a single, independently testable function: a plain `{event, data}` frame in, a fake
226
+ * `RunHandlers` to assert against, no `ReadableStream`/`fetch`/timers required.
227
+ *
228
+ * @param ctx.collected - This turn's running event log — the same array `startByokRun` hands to
229
+ * `handlers.onDone` once the stream ends.
230
+ * @param ctx.finish - `startByokRun`'s own idempotent finish (closes out the turn, deletes its abort
231
+ * controller, calls `onDone`). Called on `"end"`, exactly as the inline version did.
232
+ */
233
+ function handleByokFrame(frame, ctx) {
234
+ if (frame.event === "agent") {
235
+ const payload = JSON.parse(frame.data);
236
+ const translated = translateRunAgentPayload(payload);
237
+ if (translated) {
238
+ ctx.collected.push(translated);
239
+ ctx.handlers.onEvent(translated);
240
+ }
241
+ }
242
+ else if (frame.event === "error") {
243
+ const payload = JSON.parse(frame.data);
244
+ ctx.handlers.onError(new Error(asString(payload.message) || "BYOK turn failed"));
245
+ }
246
+ else if (frame.event === "end") {
247
+ const notice = terminalReasonNotice(readTerminalReason(frame.data, false));
248
+ if (notice) {
249
+ ctx.collected.push(notice);
250
+ ctx.handlers.onEvent(notice);
251
+ }
252
+ ctx.finish();
253
+ }
254
+ }
255
+ /**
256
+ * Drains a BYOK turn's SSE body to completion, dispatching each frame via {@link handleByokFrame}
257
+ * and settling the turn through `ctx.finish` — the async IIFE `startByokRun` used to hold its
258
+ * stream-consumer loop in, pulled out to a top-level function (2026-08-06, complexity pass, second
259
+ * pass). `startByokRun`'s own per-function score is already under the per-function ceiling; this
260
+ * split is for the OTHER metric this repo's complexity ceiling tracks — a tool that rolls a nested
261
+ * closure's branches into its enclosing function's total still counted this IIFE as part of
262
+ * `startByokRun` as long as it stayed physically inside the body. Moving it to a sibling top-level
263
+ * function removes it from that rollup instead of just moving it to a different nested scope (a
264
+ * `const` inside `startByokRun` would not have changed the rollup at all).
265
+ *
266
+ * @param ctx.runId - Only used to clear {@link byokAbortControllers} on a genuine (non-abort)
267
+ * failure — the abort path itself already deletes its own entry in `startByokRun` before this
268
+ * runs, so `ctx.runId` here is scoped to the error branch alone.
269
+ * @param ctx.controller - Read only for `.signal.aborted`, to tell a deliberate `stopRun`/composer
270
+ * abort apart from a real stream failure — same distinction the inline version drew.
271
+ */
272
+ async function consumeByokStream(body, ctx) {
273
+ try {
274
+ for await (const frame of readSseFrames(body)) {
275
+ handleByokFrame(frame, { collected: ctx.collected, handlers: ctx.handlers, finish: ctx.finish });
276
+ }
277
+ ctx.finish();
278
+ }
279
+ catch (error) {
280
+ byokAbortControllers.delete(ctx.runId);
281
+ if (ctx.controller.signal.aborted) {
282
+ // Cancelled via `stopRun` or the composer's own signal — an expected exit, not a reportable
283
+ // failure. `subscribeToRun`'s `EventSource` has no equivalent branch because `.close()`
284
+ // doesn't reject a promise the way an aborted `fetch`'s body reader does; this mirrors what a
285
+ // cancelled daemon run already looks like to the rest of the pane: silence, not an error toast.
286
+ ctx.finish();
287
+ return;
288
+ }
289
+ // Error THEN finish, same order and same reason as `subscribeToRun`'s `end` listener: reporting
290
+ // alone left the turn unsettled (no `onDone`, so the events collected before the failure were
291
+ // never persisted), and finishing first would record the dead run as succeeded.
292
+ ctx.handlers.onError(error instanceof Error ? error : new Error(String(error)));
293
+ ctx.finish();
294
+ }
295
+ }
296
+ /**
297
+ * The BYOK run path (2026-08-04) — one held-open `POST` to `assistant-byok.ts`, no separate
298
+ * `EventSource`/reattach. See module doc's path-2 section for why this shape differs from
299
+ * `subscribeToRun`'s daemon-path pattern, and what it costs (no reattach after a reload; `stopRun`
300
+ * works via the abort-controller map above instead of a server-side cancel endpoint).
301
+ *
302
+ * @complexity Dominated by the network/stream cost of the turn itself; per-frame parsing is O(1).
303
+ * @overallScore 100
304
+ */
305
+ async function startByokRun(input, handlers, byok) {
306
+ const runId = mintByokRunId();
307
+ const controller = new AbortController();
308
+ byokAbortControllers.set(runId, controller);
309
+ input.signal?.addEventListener("abort", () => controller.abort());
310
+ // `historyForTranscript` first (Defect 3, 2026-09-11): the empty-content filter below already
311
+ // hides a failed run that produced nothing, but NOT one that emitted a few tokens before dying —
312
+ // that partial output would be sent to the provider as if it were the assistant's answer. This is
313
+ // the same defect as on the Local CLI path, reachable by a second route.
314
+ const messages = historyForTranscript(input.history)
315
+ .filter((message) => message.content.trim().length > 0)
316
+ .map((message) => ({ role: message.role, content: message.content,
317
+ ...(message.secretRedaction?.secretRedacted ? { secretRedacted: true } : {}),
318
+ }));
319
+ let response;
320
+ try {
321
+ response = await ports.fetch(BYOK_TURN_URL, {
322
+ method: "POST",
323
+ headers: { "Content-Type": "application/json" },
324
+ credentials: "same-origin",
325
+ body: JSON.stringify({
326
+ messages,
327
+ ...attachmentIdsField(input),
328
+ // The admin screen, as on the Local CLI path (`buildLocalCliContextRef`): the server puts it
329
+ // in front of the newest message so a BYOK turn knows the open page and where drawings go.
330
+ ...pageContextField(input),
331
+ byok: {
332
+ protocol: byok.protocol,
333
+ apiKey: byok.apiKey,
334
+ ...(byok.baseUrl ? { baseUrl: byok.baseUrl } : {}),
335
+ model: byok.model,
336
+ ...(byok.maxTokens !== undefined ? { maxTokens: byok.maxTokens } : {}),
337
+ },
338
+ }),
339
+ signal: controller.signal,
340
+ });
341
+ }
342
+ catch (error) {
343
+ byokAbortControllers.delete(runId);
344
+ throw error instanceof Error ? error : new Error(String(error));
345
+ }
346
+ if (!response.ok || !response.body) {
347
+ byokAbortControllers.delete(runId);
348
+ const detail = await response.text().catch(() => "");
349
+ throw new Error(`BYOK turn failed to start (${response.status})${detail ? `: ${detail.slice(0, 300)}` : ""}`);
350
+ }
351
+ const collected = [];
352
+ let settled = false;
353
+ const finish = () => {
354
+ if (settled)
355
+ return;
356
+ settled = true;
357
+ byokAbortControllers.delete(runId);
358
+ handlers.onDone(collected);
359
+ };
360
+ // Deliberately not awaited: `startRun`'s contract (matching the daemon path immediately above)
361
+ // is to resolve `{runId}` once the turn has STARTED, not once it has finished — the response
362
+ // headers (hence this function reaching this point at all) arrive as soon as
363
+ // `assistant-byok.ts` calls `beginStream`, well before generation completes.
364
+ void consumeByokStream(response.body, { runId, collected, handlers, finish, controller });
365
+ return { runId };
366
+ }
367
+ /**
368
+ * Which browser tab a Local CLI run should be allowed to drive, from `ChatPane`'s `runContext` prop
369
+ * (`AssistantDock.tsx` supplies it from the live `FrontendSessionBridge`).
370
+ *
371
+ * Read by name rather than spreading the whole `input.context` blob: `contextRef` is a shared
372
+ * envelope that the host's proxy also writes `principalId` into (`src/server/modules/assistant.ts`),
373
+ * and a spread would let any future `runContext` key silently shadow it — an identity field being
374
+ * overwritten by a UI prop is not a failure mode worth leaving open to save one line.
375
+ *
376
+ * Returns `{}` (omitting the field entirely) when absent, which is a normal state, not an error:
377
+ * the daemon treats a run with no bind token as one with no screen to drive
378
+ * (`agent-daemon-server.ts`).
379
+ */
380
+ function frontendBindTokenField(input) {
381
+ const frontendBindToken = input.context?.["frontendBindToken"];
382
+ return typeof frontendBindToken === "string" && frontendBindToken.length > 0 ? { frontendBindToken } : {};
383
+ }
384
+ /**
385
+ * The Local CLI picker's live model selection, from `ChatPane`'s `runContext` prop
386
+ * (`AssistantDock.tsx`'s `resolveRunContext`). Same "read by name, not spread" reasoning as
387
+ * {@link frontendBindTokenField} above, and the same "omit when absent" convention. Forwarded as an
388
+ * opaque string — `agent-daemon-server.ts` forwards it the same way, and `AgentExecutor.run()`'s
389
+ * def-level `buildArgs` is what decides what an absent or `'default'` value means for a given CLI
390
+ * (`@jini-ai/agent-runtime`'s `models.ts`/`resolveModelForAgent`).
391
+ */
392
+ function modelField(input) {
393
+ const model = input.context?.["model"];
394
+ return typeof model === "string" && model.length > 0 ? { model } : {};
395
+ }
396
+ /**
397
+ * The Execution tab's "Reasoning effort" pick, from the same `runContext` seam as {@link modelField}
398
+ * above and forwarded with the identical "opaque string, omit when absent" convention. What turns it
399
+ * into real argv is the def's own `buildArgs` on the daemon side (`claude --effort <level>`, codex's
400
+ * `-c model_reasoning_effort=...`) — nothing here interprets it, and a runtime whose effort is
401
+ * encoded in the model id (antigravity) never sends it at all, because its level is already inside
402
+ * `model`.
403
+ */
404
+ function reasoningField(input) {
405
+ const reasoning = input.context?.["reasoning"];
406
+ return typeof reasoning === "string" && reasoning.length > 0 ? { reasoning } : {};
407
+ }
408
+ /**
409
+ * Opaque `attachment:<uuid>` capability ids (`ChatAttachment.path` — never a real filesystem path
410
+ * this early; see `@jini-ai/http-kit`'s `attachments.ts` trust-model doc), not the attachments
411
+ * themselves — `contextRef` is the one channel `prompt`/`frontendBindToken` already ride on to reach
412
+ * `agent-daemon-server.ts`'s `onStarted`, which is where these ids get exchanged for real,
413
+ * re-validated paths via `AttachmentStore.claim()`. Nothing on this side of the wire is trusted; the
414
+ * id is inert until the daemon claims it.
415
+ *
416
+ * Returns `{}`, same convention as {@link frontendBindTokenField} above — a run with no attachments
417
+ * is the overwhelmingly common case and should not carry a key for it.
418
+ */
419
+ function attachmentIdsField(input) {
420
+ return input.attachments && input.attachments.length > 0
421
+ ? { attachmentIds: input.attachments.map((attachment) => attachment.path) }
422
+ : {};
423
+ }
424
+ /**
425
+ * Opaque Agent Plugin ids (`plugin.json`'s own `name`, e.g. `"ui-ux-design"`) the operator has
426
+ * pinned as composer chips — `AssistantDock.tsx`'s `useSelectedAgentPlugins`, threaded here via
427
+ * `ChatPane`'s `runContext` prop the same way {@link frontendBindTokenField}/{@link modelField}
428
+ * above already are. Same "read by name, not spread" and "omit when absent" conventions as those
429
+ * two fields: this is not the plugin's CONTENT, only a reference to it —
430
+ * `agent-daemon-server.ts`'s `onStarted` is where a ref gets resolved against the real installed
431
+ * package on disk and its own text prepended to the prompt (see that function's own doc for the
432
+ * resolution/failure rules). Filtered to non-empty strings for the same reason `attachmentIds` is
433
+ * filtered on the decode side (`run-start-context.ts`'s `parseRunStartContextRef`) — this is the
434
+ * encode side of the same wire value, and a malformed entry here should not silently become a
435
+ * malformed one there.
436
+ */
437
+ function pluginRefIdsField(input) {
438
+ const pluginRefIds = input.context?.["pluginRefIds"];
439
+ if (!Array.isArray(pluginRefIds))
440
+ return {};
441
+ const filtered = pluginRefIds.filter((id) => typeof id === "string" && id.length > 0);
442
+ return filtered.length > 0 ? { pluginRefIds: filtered } : {};
443
+ }
444
+ /**
445
+ * The active conversation id, from `ChatPane`'s `runContext` prop (`AssistantDock.tsx`'s
446
+ * `chats.activeId`, the same `useRunContext` seam {@link frontendBindTokenField}/{@link modelField}/
447
+ * {@link pluginRefIdsField} above already ride). Lets `agent-daemon-server.ts`'s `onStarted` resume
448
+ * this conversation's agent-CLI session across turns instead of spawning cold every time
449
+ * (`server/inbound/assistant/agent-session-resume.ts`) — see that file's own doc. Returns `{}`, same
450
+ * convention as every other optional field here: a run started before any conversation exists yet
451
+ * (or from a future daemon client that never sends one) just gets no session-resume behavior, not
452
+ * an error.
453
+ */
454
+ function conversationIdField(input, resolvedConversationId) {
455
+ const conversationId = resolvedConversationId ?? input.context?.["conversationId"];
456
+ return typeof conversationId === "string" && conversationId.length > 0 ? { conversationId } : {};
457
+ }
458
+ /**
459
+ * The admin screen this message was sent from (`lib/agent-screen-context.ts`, read by
460
+ * `AssistantDock.hooks.tsx`'s `useRunContext` at send time), so the agent knows what "this page"
461
+ * means. Forwarded as-is when it is a plain object — the daemon's `run-page-context.ts` owns
462
+ * validating and capping its fields, the same split `attachmentIds` has — and omitted otherwise.
463
+ */
464
+ function pageContextField(input) {
465
+ const pageContext = input.context?.["pageContext"];
466
+ return typeof pageContext === "object" && pageContext !== null && !Array.isArray(pageContext) ? { pageContext } : {};
467
+ }
468
+ /**
469
+ * Assembles the Local CLI path's `contextRef` — everything `startRun`'s daemon branch sends besides
470
+ * `agentId` itself. Pulled out of `startRun` (2026-08-06, complexity pass, second pass) as its own
471
+ * pure function, then split again (2026-09-04, complexity pass) into one pure field-helper per
472
+ * optional field, each above: six independent, unrelated fields, none sharing state or order
473
+ * dependence, each read from `input` by name and included only when present (see each helper's own
474
+ * doc for why). Kept as six separate functions rather than grouped, on purpose — the fields have no
475
+ * conceptual relationship to group them by (identity, execution config, and content all mixed
476
+ * together), and inventing a grouping would obscure the very independence each field's own doc
477
+ * argues for. A plain object in, a plain object out — directly testable with a `StartRunInput`
478
+ * fixture, no `fetch`/`EventSource` involved.
479
+ *
480
+ * @param prompt - The already-built transcript string ({@link runPrompt}'s result) — this function
481
+ * only decides which of the six OPTIONAL fields ride alongside it, not how the prompt itself is
482
+ * built.
483
+ * @param resolvedConversationId - {@link resolveRunConversationId}'s answer, when `startRun` had to
484
+ * adopt a conversation because `input.context` named none (the first turn of a chat). Omitted by
485
+ * every caller that already has an id on `input.context`, and by this function's own unit tests —
486
+ * in which case the `context` field is read exactly as before.
487
+ */
488
+ function buildLocalCliContextRef(input, prompt, resolvedConversationId) {
489
+ return {
490
+ prompt: ports.projectPrompt({ prompt, context: input.context }, {}),
491
+ // The transcript is already sanitized; retain its signal across the daemon JSON boundary.
492
+ ...(input.history.some(message => message.role === "user" && message.secretRedaction?.secretRedacted) ? { secretRedacted: true } : {}),
493
+ assistantMessageId: input.assistantMessageId,
494
+ ...frontendBindTokenField(input),
495
+ ...modelField(input),
496
+ ...reasoningField(input),
497
+ ...attachmentIdsField(input),
498
+ ...pluginRefIdsField(input),
499
+ ...conversationIdField(input, resolvedConversationId),
500
+ ...pageContextField(input),
501
+ };
502
+ }
503
+ /**
504
+ * The conversation id this run must carry, adopting one if the caller had none.
505
+ *
506
+ * @param input - `startRun`'s own input; `input.context.conversationId` wins whenever it is a
507
+ * non-empty string, so turn 2 onward never touches {@link ensureConversationId} and never issues a
508
+ * redundant `POST /conversations`.
509
+ * @param ensureConversationId - `AssistantTransportOptions.ensureConversationId`, or
510
+ * `undefined` for a transport built without it (every test and any non-dock consumer).
511
+ * @returns The id to put on the wire, or `undefined` to omit the key entirely — including when
512
+ * adoption failed. The `.catch` is load-bearing: `ensureConversationId`'s own contract promises
513
+ * `null` over a rejection, and this makes that promise safe to depend on even if a future
514
+ * implementation breaks it, rather than failing the user's turn over a lost session id.
515
+ * @complexity O(1) plus at most one conversation-creation round trip, on the first turn only.
516
+ */
517
+ async function resolveRunConversationId(input, ensureConversationId) {
518
+ const fromContext = input.context?.["conversationId"];
519
+ if (typeof fromContext === "string" && fromContext.length > 0)
520
+ return fromContext;
521
+ if (!ensureConversationId)
522
+ return undefined;
523
+ const adopted = await ensureConversationId({}, {}).catch(() => null);
524
+ return typeof adopted === "string" && adopted.length > 0 ? adopted : undefined;
525
+ }
526
+ /**
527
+ * Resolves the prompt string `startRun`'s Local CLI branch sends: the full rendered transcript
528
+ * ({@link runPrompt}) by default, or only what the agent has not already been given
529
+ * ({@link undeliveredUserPrompt}) when `input.agentId` is present in `resumeCapableAgentIds` — see
530
+ * `getResumeCapableAgentIds`'s own doc on {@link AssistantTransportOptions} for the full
531
+ * contract this mirrors. Pulled out of `startRun` (2026-09-04, complexity pass) as its own pure
532
+ * step. `options.getResumeCapableAgentIds?.({}, {})` is still called at the same point in `startRun` as
533
+ * before — only the "which agentIds carries own memory" branch itself moves here, so the "read fresh
534
+ * on every call" convention that doc argues for is unchanged.
535
+ */
536
+ function resolveLocalCliPrompt(input, resumeCapableAgentIds) {
537
+ const carriesOwnMemory = input.agentId !== undefined && resumeCapableAgentIds?.has(input.agentId) === true;
538
+ // BEHAVIOR CHANGE (2026-09-11, Defect 2): the resume-capable branch was `latestUserPrompt` — the
539
+ // single newest user turn, on the assumption that everything before it is already inside the
540
+ // CLI's own session. That assumption holds only for turns the agent actually answered; a turn
541
+ // whose run died before the CLI read its prompt is in neither place, and nothing ever sent it
542
+ // again. `undeliveredUserPrompt` returns the identical single string in the ordinary case and
543
+ // differs only when there is genuinely something unanswered to catch up on. The
544
+ // `latestUserPrompt` parameter this used to take went with it: `startRun` still computes that
545
+ // string for its own send guard, but this branch has no use for it any more.
546
+ return carriesOwnMemory ? undeliveredUserPrompt(input.history) : runPrompt(input.history);
547
+ }
548
+ /**
549
+ * Writes the newest user turn to durable storage before its run is dispatched, and never fails the
550
+ * turn over it.
551
+ *
552
+ * Defect 2 of the 2026-09-11 chat-lifecycle repair. Message persistence is a client-side
553
+ * `Promise.all` driven by `onMessagesChange` deltas (`use-assistant-chats.hooks.ts`'s `flush`) and
554
+ * run dispatch is a separate `POST /api/runs` here — nothing orders them and nothing makes them
555
+ * atomic, so a browser or daemon death in the window between them could leave a dispatched run whose
556
+ * prompt existed nowhere durable. Await the same PUT when a delta has already scheduled it;
557
+ * acceptance also carries the exact user message and commits it with the assistant stub when
558
+ * browser persistence fails, so sending does not depend on a successful separate PUT.
559
+ *
560
+ * The idempotency key is the message's own id — `assistant-chats.ts`'s `saveMessage` PUTs to
561
+ * `/messages/<id>`, so this write and the `flush` that will race it converge on one row rather than
562
+ * two, and a retry of the same content is free. `useAssistantChats`'s own `persistUserTurn` also
563
+ * marks the id written, so in practice `flush` skips it entirely.
564
+ *
565
+ * @param input - `startRun`'s own input; the newest `role: "user"` message in its history is what
566
+ * gets written.
567
+ * @param conversationId - The already-resolved conversation (see `resolveRunConversationId`), or
568
+ * `undefined` when adoption failed — there is no row to write into in that case.
569
+ * @param persistUserTurn - `AssistantTransportOptions.persistUserTurn`, or `undefined`
570
+ * for a transport built without it (every test and any non-dock consumer).
571
+ * @returns Nothing, always. The `.catch` keeps a failed browser PUT from refusing the user's turn;
572
+ * durable acceptance is now responsible for committing its copy before dispatch.
573
+ * @complexity O(n) in history length for the newest-user-turn scan, plus one PUT.
574
+ */
575
+ async function persistUserTurnBeforeDispatch(input, conversationId, persistUserTurn) {
576
+ if (conversationId === undefined || persistUserTurn === undefined)
577
+ return;
578
+ const history = input.history;
579
+ const message = [...history].reverse().find((candidate) => candidate.role === "user");
580
+ if (message === undefined)
581
+ return;
582
+ await persistUserTurn({ conversationId, message }, {}).catch(() => undefined);
583
+ }
584
+ /**
585
+ * Turns `startRun`'s Local CLI `POST /api/runs` response into either a thrown, detailed error or a
586
+ * subscribed run — the two outcomes once the request lands. Pulled out of `startRun` (2026-09-04,
587
+ * complexity pass) as its own step: `response.ok` in, `{runId}` or a thrown `Error` out. The
588
+ * `.text().catch(() => "")` is load-bearing, not decorative — see the inline comment below.
589
+ */
590
+ async function finishLocalCliRun(response, handlers, signal) {
591
+ if (!response.ok) {
592
+ // A response whose body stream errors out (e.g. a dropped connection) must not turn "the server
593
+ // rejected the request" into "the client crashed reading the rejection".
594
+ const detail = await response.text().catch(() => "");
595
+ throw new Error(`agent run failed to start (${response.status})${detail ? `: ${detail.slice(0, 300)}` : ""}`);
596
+ }
597
+ const { run, messageId, conversationId } = (await response.json());
598
+ if (messageId && conversationId)
599
+ ports.bindRun({ runId: run.id, binding: { messageId, conversationId } }, {});
600
+ subscribeToRun(run.id, handlers, signal);
601
+ return { runId: run.id };
602
+ }
603
+ function createAssistantTransport(options = {}) {
604
+ return {
605
+ async startRun(input, handlers) {
606
+ // Guard on the newest USER turn, not on the assembled transcript: a history containing only
607
+ // assistant messages would still produce a non-empty transcript, and sending that as a
608
+ // prompt asks the agent to reply to itself. Shared by both paths below, and reused (rather than
609
+ // recomputed) by the Local CLI branch's own resume-capable check further down.
610
+ const latestUserPrompt = latestUserPromptFromHistory({ history: input.history });
611
+ if (!latestUserPrompt) {
612
+ throw new Error("no user message to send");
613
+ }
614
+ const executionConfig = options.getExecutionConfig?.({}, {});
615
+ // Dispatches on MODE alone, not on whether a key is typed in this browser right now
616
+ // (2026-08-05). `executionConfig.byok.apiKey` is write-only server-side — it is empty on
617
+ // every fresh load even when a credential IS stored — so gating on it here would make BYOK
618
+ // mode permanently unable to dispatch for exactly the case the server-side store exists to
619
+ // support. `assistant-byok.ts`'s route already resolves the credential correctly either way
620
+ // (a locally-typed key wins when present; an empty/omitted one falls back to this admin's own
621
+ // stored row), so an empty `byok.apiKey` is a legitimate turn, not a reason to fall through to
622
+ // the Local CLI path. A turn with genuinely no usable credential anywhere still fails, just one
623
+ // level down — the route's own 400 `"no usable BYOK credential..."` — which is a real,
624
+ // actionable answer instead of the mode picker silently refusing to try.
625
+ if (executionConfig?.mode === "byok") {
626
+ return startByokRun(ports.projectInput({ input }, {}), handlers, executionConfig.byok);
627
+ }
628
+ // AG-UI canary path (ADR-059) — a host-supplied toggle, independent of `executionConfig.mode`
629
+ // (Jini's own closed union, never extended for this). Checked AFTER the byok branch above:
630
+ // BYOK's single-request shape never touches the daemon at all, so there is nothing for this
631
+ // path (which wraps the daemon specifically) to intercept there — the toggle only ever
632
+ // diverts the Local CLI branch below.
633
+ if (options.getAgUiEnabled?.({}, {})) {
634
+ // `historyForTranscript` here rather than inside `startAgUiRun`: that module importing this
635
+ // one would be a cycle, and this is its only caller. Same rule as the BYOK and Local CLI paths.
636
+ return ports.agUi.startAgUiRun({ input: ports.projectInput({ input: { ...input, history: historyForTranscript(input.history) } }, {}), handlers }, {});
637
+ }
638
+ // Local CLI path below. `resolveLocalCliPrompt`'s own doc has the full contract for why an
639
+ // unresolved/absent agentId fails open to the full transcript rather than the bare message.
640
+ const prompt = resolveLocalCliPrompt(input, options.getResumeCapableAgentIds?.({}, {}));
641
+ // Awaited BEFORE the POST, and only on this branch: session resume is a daemon-run concept, so
642
+ // neither the BYOK nor the AG-UI path above has anything to file a session id under. See
643
+ // `resolveRunConversationId` and `AssistantTransportOptions.ensureConversationId`.
644
+ const conversationId = await resolveRunConversationId(input, options.ensureConversationId);
645
+ // Also before the POST, and for the same reason it is awaited rather than fired off: the
646
+ // user's turn must be durable before a run that can fail to deliver it exists at all
647
+ // (Defect 2, 2026-09-11). Ordered after conversation adoption because it needs the id that
648
+ // adoption produces. Known gap, not an oversight: the BYOK and AG-UI branches above return
649
+ // before this point and resolve no conversation id of their own, so neither writes a durable
650
+ // user turn ahead of dispatch — closing that needs those paths to adopt a conversation first.
651
+ await persistUserTurnBeforeDispatch(input, conversationId, options.persistUserTurn);
652
+ const userMessage = [...input.history].reverse().find((message) => message.role === "user");
653
+ // Acceptance commits the question and stub together even if the separate browser PUT fails
654
+ // or the tab disappears. Carry the exact message ID/payload, never the rendered prompt.
655
+ const base = buildLocalCliContextRef(input, prompt, conversationId);
656
+ const contextRef = userMessage ? { ...base, userMessage } : base;
657
+ const response = await ports.acceptRun({
658
+ ...(input.cancelSignal === undefined ? {} : { signal: input.cancelSignal }),
659
+ requestSignal: input.cancelSignal ?? input.signal,
660
+ body: { contextRef: JSON.stringify(contextRef), agentId: input.agentId, idempotencyKey: base.assistantMessageId },
661
+ messageId: base.assistantMessageId,
662
+ ...(conversationId === undefined ? {} : { conversationId }),
663
+ }, {});
664
+ return finishLocalCliRun(response, handlers, input.signal);
665
+ },
666
+ async reattachRun(runId, handlers, options) {
667
+ // A BYOK run has no server-side record to reattach to (module doc's path-2 section) — the
668
+ // stream lived entirely on the original `fetch()`'s response body, which a reload has already
669
+ // discarded. Reporting the run as simply over (an empty `onDone`) is the honest answer: there
670
+ // is no way to resume it, and pretending otherwise would hang the pane waiting for events that
671
+ // can never arrive.
672
+ if (runId.startsWith(BYOK_RUN_ID_PREFIX)) {
673
+ handlers.onDone([]);
674
+ return;
675
+ }
676
+ // Same "no server-side record to resume" reasoning as the BYOK branch above — see
677
+ // `assistant-transport-ag-ui.ts`'s own doc for why this path has no reattach story yet.
678
+ if (ports.agUi.isAgUiRunId({ runId }, {})) {
679
+ return ports.agUi.reattachAgUiRun({ handlers }, {});
680
+ }
681
+ subscribeToRun(runId, handlers, options?.signal);
682
+ },
683
+ async fetchRunStatus(runId) {
684
+ // Same reasoning as `reattachRun` above — no server-side run record exists for a BYOK run id,
685
+ // so there is no status to fetch. `null` is this port's existing "unknown/not trackable"
686
+ // value (see the daemon branch below's own `!response.ok` case), not a new state.
687
+ if (runId.startsWith(BYOK_RUN_ID_PREFIX))
688
+ return null;
689
+ if (ports.agUi.isAgUiRunId({ runId }, {}))
690
+ return ports.agUi.fetchAgUiRunStatus({}, {});
691
+ const response = await ports.fetch(`${RUNS_URL}/${encodeURIComponent(runId)}`, { credentials: "same-origin" });
692
+ if (!response.ok)
693
+ return null;
694
+ const { run } = (await response.json());
695
+ return toChatCoreRunStatus(run.state) ?? null;
696
+ },
697
+ async stopRun(runId) {
698
+ // A BYOK run's only cancellation handle is the abort controller `startByokRun` registered for
699
+ // this exact id — there is no server-side run to `POST .../cancel` against.
700
+ if (runId.startsWith(BYOK_RUN_ID_PREFIX)) {
701
+ byokAbortControllers.get(runId)?.abort();
702
+ return;
703
+ }
704
+ if (ports.agUi.isAgUiRunId({ runId }, {})) {
705
+ return ports.agUi.stopAgUiRun({ runId }, {});
706
+ }
707
+ const response = await ports.fetch(`${RUNS_URL}/${encodeURIComponent(runId)}/cancel`, {
708
+ method: "POST",
709
+ headers: { "Content-Type": "application/json" },
710
+ credentials: "same-origin",
711
+ body: JSON.stringify({ runId, messageId: ports.runBinding({ runId }, {})?.messageId }),
712
+ });
713
+ // 404: the daemon no longer knows this run (it restarted), so nothing is left running to stop.
714
+ if (response.status === 404)
715
+ return;
716
+ // `fetch` resolves on any status: without this a refused cancel (401/403/500) resolved exactly
717
+ // like a successful one while the run kept executing. `useRunStream` logs a rejection here.
718
+ if (!response.ok) {
719
+ const detail = await response.text().catch(() => "");
720
+ throw new Error(`cancelling agent run failed (${response.status})${detail ? `: ${detail.slice(0, 300)}` : ""}`);
721
+ }
722
+ },
723
+ };
724
+ }
725
+ return {
726
+ createAssistantTransport: ({ options = {} }, _optional = {}) => createAssistantTransport(options),
727
+ historyForTranscript: ({ history }, _optional = {}) => historyForTranscript(history),
728
+ undeliveredUserPrompt: ({ history }, _optional = {}) => undeliveredUserPrompt(history),
729
+ buildLocalCliContextRef: ({ input, prompt, resolvedConversationId }, _optional = {}) => buildLocalCliContextRef(input, prompt, resolvedConversationId),
730
+ handleByokFrame: ({ frame, ctx }, _optional = {}) => handleByokFrame(frame, ctx),
731
+ consumeByokStream: ({ body, ctx }, _optional = {}) => consumeByokStream(body, ctx),
732
+ };
733
+ }
734
+ //# sourceMappingURL=transport.js.map