@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.
- package/CHANGELOG.md +11 -0
- package/dist/core/ag-ui/entry.d.ts +33 -0
- package/dist/core/ag-ui/entry.js +80 -0
- package/dist/core/agentic/chat-capabilities.d.ts +2 -4
- package/dist/core/agentic/index.d.ts +4 -17
- package/dist/core/agentic/index.js +4 -17
- package/dist/core/assistant-client/a2ui-action-poster.d.ts +46 -0
- package/dist/core/assistant-client/a2ui-action-poster.js +113 -0
- package/dist/core/assistant-client/ag-ui-transport.d.ts +68 -0
- package/dist/core/assistant-client/ag-ui-transport.js +147 -0
- package/dist/core/assistant-client/attachment-liveness.d.ts +37 -0
- package/dist/core/assistant-client/attachment-liveness.js +93 -0
- package/dist/core/assistant-client/durable-run-subscription.d.ts +39 -0
- package/dist/core/assistant-client/durable-run-subscription.js +96 -0
- package/dist/core/assistant-client/entry.d.ts +13 -0
- package/dist/core/assistant-client/entry.js +7 -0
- package/dist/core/assistant-client/messages.d.ts +73 -0
- package/dist/core/assistant-client/messages.js +68 -0
- package/dist/core/assistant-client/transport.d.ts +164 -0
- package/dist/core/assistant-client/transport.js +734 -0
- package/dist/core/browser/page-actions.d.ts +121 -0
- package/dist/core/browser/page-actions.js +113 -0
- package/dist/core/composer-history.d.ts +54 -0
- package/dist/core/composer-history.js +57 -0
- package/dist/core/durable-projection.d.ts +28 -0
- package/dist/core/durable-projection.js +71 -0
- package/dist/core/durable-runs/continuation.d.ts +18 -0
- package/dist/core/durable-runs/continuation.js +61 -0
- package/dist/core/durable-runs/ledger-contracts.d.ts +53 -0
- package/dist/core/durable-runs/ledger-contracts.js +2 -0
- package/dist/core/durable-runs/policies.d.ts +6 -0
- package/dist/core/durable-runs/policies.js +6 -0
- package/dist/core/durable-runs/ports.d.ts +123 -0
- package/dist/core/durable-runs/ports.js +2 -0
- package/dist/core/durable-runs/run-contracts.d.ts +17 -0
- package/dist/core/durable-runs/run-contracts.js +2 -0
- package/dist/core/durable-runs/run-lock.d.ts +4 -0
- package/dist/core/durable-runs/run-lock.js +4 -0
- package/dist/core/index.d.ts +9 -19
- package/dist/core/index.js +8 -19
- package/dist/core/messages.d.ts +2 -0
- package/dist/core/question-form/format.d.ts +0 -9
- package/dist/core/question-form/format.js +16 -2
- package/dist/core/question-form/normalize.js +1 -0
- package/dist/core/question-form/secret-answers.d.ts +38 -0
- package/dist/core/question-form/secret-answers.js +45 -0
- package/dist/core/question-form/types.d.ts +2 -0
- package/dist/core/question-form.d.ts +1 -0
- package/dist/core/question-form.js +1 -0
- package/dist/core/run-events/entry.d.ts +208 -16
- package/dist/core/run-events/entry.js +273 -39
- package/dist/core/transcript.d.ts +3 -2
- package/dist/core/transcript.js +19 -3
- package/dist/core/transport.d.ts +4 -0
- package/dist/core/user-text-redaction.d.ts +56 -0
- package/dist/core/user-text-redaction.js +89 -0
- package/dist/embed-widget/browser/page-actions.d.ts +1 -120
- package/dist/embed-widget/browser/page-actions.js +2 -112
- package/dist/embed-widget/react/embed.d.ts +1 -1
- package/dist/embed-widget/react/embed.js +1 -1
- package/dist/react/artifact-types.d.ts +9 -15
- package/dist/react/components/Composer.js +6 -0
- package/dist/react/components/MessageRow.js +24 -11
- package/dist/react/components/QuestionForm.d.ts +2 -1
- package/dist/react/components/QuestionForm.js +18 -12
- package/dist/react/components/QuestionsPanel.d.ts +3 -2
- package/dist/react/components/QuestionsPanel.js +2 -2
- package/dist/react/features/chat-pane/agent-tools.d.ts +1 -1
- package/dist/react/features/chat-pane/agent-tools.js +0 -1
- package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +5 -2
- package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
- package/dist/react/features/chat-pane/components/ChatPane.js +9 -4
- package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +2 -1
- package/dist/react/features/chat-pane/create-typed-answer-poster.js +33 -1
- package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +3 -2
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +7 -1
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +38 -23
- package/dist/react/features/chat-pane/model-options.d.ts +10 -0
- package/dist/react/features/chat-pane/model-options.js +51 -0
- package/dist/react/features/chat-pane/rules.d.ts +3 -2
- package/dist/react/features/chat-pane/rules.js +12 -4
- package/dist/react/features/chat-pane/types.d.ts +30 -1
- package/dist/react/hooks/assistant-chats-contracts.d.ts +164 -0
- package/dist/react/hooks/assistant-chats-contracts.js +2 -0
- package/dist/react/hooks/assistant-chats.d.ts +7 -0
- package/dist/react/hooks/assistant-chats.js +960 -0
- package/dist/react/hooks/attachment-preview-cache.d.ts +1 -1
- package/dist/react/hooks/attachment-preview-cache.js +1 -1
- package/dist/react/hooks/composer-draft-cache.d.ts +2 -2
- package/dist/react/hooks/composer-draft-cache.js +2 -2
- package/dist/react/hooks/composer-history-storage.d.ts +8 -0
- package/dist/react/hooks/composer-history-storage.js +40 -0
- package/dist/react/hooks/fab-position.d.ts +90 -0
- package/dist/react/hooks/fab-position.js +271 -0
- package/dist/react/hooks/useComposer.d.ts +10 -1
- package/dist/react/hooks/useComposer.js +14 -2
- package/dist/react/hooks/useComposerHistory.hooks.d.ts +15 -0
- package/dist/react/hooks/useComposerHistory.hooks.js +90 -0
- package/dist/react/hooks/useComposerHistoryKeys.hooks.d.ts +9 -0
- package/dist/react/hooks/useComposerHistoryKeys.hooks.js +90 -0
- package/dist/react/hooks/useConversation.d.ts +4 -1
- package/dist/react/hooks/useConversation.js +20 -14
- package/dist/react/hooks/useRunStream.js +12 -2
- package/dist/react/hooks/useUserTextGuard.d.ts +11 -0
- package/dist/react/hooks/useUserTextGuard.js +19 -0
- package/dist/react/index.d.ts +14 -21
- package/dist/react/index.js +8 -0
- package/dist/server/durable-runs/accept.d.ts +25 -0
- package/dist/server/durable-runs/accept.js +21 -0
- package/dist/server/durable-runs/entry.d.ts +5 -0
- package/dist/server/durable-runs/entry.js +4 -0
- package/dist/server/durable-runs/recover.d.ts +5 -0
- package/dist/server/durable-runs/recover.js +147 -0
- package/dist/server/run-finalizer/ports.d.ts +2 -16
- package/dist/store/legacy/sqlite/messages.d.ts +24 -18
- package/dist/store/legacy/sqlite/messages.js +12 -2
- package/dist/store/pglite/index.d.ts +2 -1
- package/dist/store/postgres/index.d.ts +2 -1
- package/dist/store/runtime/chat-expiry-sweep.d.ts +40 -0
- package/dist/store/runtime/chat-expiry-sweep.js +70 -0
- package/dist/store/runtime/chat-history-store.d.ts +20 -0
- package/dist/store/runtime/chat-history-store.js +50 -0
- package/dist/store/runtime/durable-run-store.d.ts +21 -0
- package/dist/store/runtime/durable-run-store.js +342 -0
- package/dist/store/runtime/entry.d.ts +7 -0
- package/dist/store/runtime/entry.js +6 -0
- package/dist/store/runtime/kernel.d.ts +29 -0
- package/dist/store/runtime/kernel.js +2 -0
- package/dist/store/runtime/run-guarded-chat-store.d.ts +10 -0
- package/dist/store/runtime/run-guarded-chat-store.js +42 -0
- package/dist/store/runtime/run-ledger.d.ts +15 -0
- package/dist/store/runtime/run-ledger.js +206 -0
- package/dist/store/sql/boundary.d.ts +6 -0
- package/dist/store/sql/boundary.js +7 -1
- package/dist/store/sql/store.d.ts +3 -2
- package/dist/store/sql/store.js +9 -2
- package/dist/store/sqlite/index.d.ts +3 -2
- 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
|