@jini-ai/chat 0.6.17 → 0.6.21
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 +15 -0
- package/dist/core/accepted-input.d.ts +10 -2
- package/dist/core/accepted-input.js +87 -33
- package/dist/core/assistant-client/durable-run-subscription.js +6 -0
- package/dist/core/assistant-client/entry.d.ts +1 -1
- package/dist/core/assistant-client/entry.js +1 -1
- package/dist/core/assistant-client/messages.d.ts +21 -0
- package/dist/core/assistant-client/messages.js +31 -0
- package/dist/core/checkpoint-interval.d.ts +25 -0
- package/dist/core/checkpoint-interval.js +38 -0
- package/dist/core/durable-runs/ledger-contracts.d.ts +27 -1
- package/dist/core/durable-runs/ports.d.ts +25 -1
- package/dist/core/events.d.ts +6 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/run-events/entry.js +15 -2
- package/dist/core/surface-recovery.d.ts +19 -0
- package/dist/core/surface-recovery.js +26 -0
- package/dist/core/tool-events.d.ts +11 -3
- package/dist/core/tool-events.js +75 -43
- package/dist/core/turn-usage.d.ts +1 -1
- package/dist/core/turn-usage.js +5 -3
- package/dist/embed-widget/browser/frontend-session-bridge.d.ts +14 -1
- package/dist/embed-widget/browser/frontend-session-bridge.js +120 -47
- package/dist/react/components/Composer.d.ts +3 -3
- package/dist/react/components/Composer.js +5 -4
- package/dist/react/components/ConversationList.js +2 -1
- package/dist/react/components/ConversationTitleEditor.js +2 -1
- package/dist/react/components/ExtEventErrorBoundary.js +2 -1
- package/dist/react/components/McpUiSurfaceCard.js +12 -5
- package/dist/react/components/MessageList.js +2 -1
- package/dist/react/components/MidRunUserMessage.js +1 -1
- package/dist/react/components/ToolCard.js +3 -2
- package/dist/react/components/ToolCard.rules.js +2 -1
- package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +5 -3
- package/dist/react/features/chat-pane/components/ChatPane.js +1 -1
- package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.d.ts +15 -2
- package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +19 -3
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +5 -4
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +17 -9
- package/dist/react/features/chat-pane/hooks/useChatPaneFileDrop.hooks.js +3 -2
- package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +20 -3
- package/dist/react/features/chat-pane/model-options.d.ts +10 -0
- package/dist/react/features/chat-pane/model-options.js +22 -3
- package/dist/react/features/chat-pane/styles.d.ts +1 -1
- package/dist/react/features/chat-pane/styles.js +3 -1
- package/dist/react/features/chat-pane/surface-recovery-stream.d.ts +12 -0
- package/dist/react/features/chat-pane/surface-recovery-stream.js +47 -0
- package/dist/react/hooks/composer-draft-cache.d.ts +6 -6
- package/dist/react/hooks/composer-draft-cache.js +18 -17
- package/dist/react/hooks/useComposer.js +52 -4
- package/dist/react/hooks/useConversation.js +41 -22
- package/dist/react/hooks/useRunStream.js +2 -1
- package/dist/react/hooks/useSurfaceRecovery.d.ts +23 -0
- package/dist/react/hooks/useSurfaceRecovery.js +84 -0
- package/dist/react/styles/reference.css +4 -5
- package/dist/server/durable-runs/recover.js +3 -3
- package/dist/store/ports.d.ts +17 -3
- package/dist/store/runtime/chat-expiry-sweep.js +3 -1
- package/dist/store/runtime/chat-history-store.js +6 -0
- package/dist/store/runtime/checkpoint-delta.d.ts +35 -0
- package/dist/store/runtime/checkpoint-delta.js +75 -0
- package/dist/store/runtime/durable-run-store.js +92 -25
- package/dist/store/runtime/events-json.d.ts +17 -0
- package/dist/store/runtime/events-json.js +19 -0
- package/dist/store/runtime/run-guarded-chat-store.js +8 -3
- package/dist/store/runtime/run-ledger.d.ts +2 -2
- package/dist/store/runtime/run-ledger.js +120 -45
- package/dist/store/sql/read-version.d.ts +15 -0
- package/dist/store/sql/read-version.js +21 -0
- package/dist/store/sql/store.d.ts +9 -0
- package/dist/store/sql/store.js +89 -4
- package/dist/store/sqlite/schema.d.ts +2 -2
- package/dist/store/sqlite/schema.js +4 -0
- package/package.json +12 -12
package/dist/core/tool-events.js
CHANGED
|
@@ -1,21 +1,6 @@
|
|
|
1
1
|
import { canonicalJson } from '@jini-ai/core/records';
|
|
2
2
|
import { toolPresentation } from './tool-presentation.js';
|
|
3
|
-
|
|
4
|
-
* Drop repeated `tool_use` events that share an `id`. A retried/duplicated
|
|
5
|
-
* stream can replay the same tool-call id (e.g. reconnect-and-replay landing
|
|
6
|
-
* an event twice); rendering it twice would show a phantom second call.
|
|
7
|
-
* Non-`tool_use` events (and the first occurrence of each id) pass through
|
|
8
|
-
* unchanged and in original order.
|
|
9
|
-
*
|
|
10
|
-
* @param events - The event list to dedupe, or `undefined` for an empty conversation turn.
|
|
11
|
-
* @returns A new array with later duplicate `tool_use` events removed, or the
|
|
12
|
-
* original array reference when nothing needed to change (cheap no-op for
|
|
13
|
-
* the common case).
|
|
14
|
-
* @complexity O(n) time / O(k) extra space, where `n` is `events.length` and
|
|
15
|
-
* `k` is the number of distinct `tool_use` ids seen — one linear pass with
|
|
16
|
-
* a `Set` membership check.
|
|
17
|
-
*/
|
|
18
|
-
export function dedupeToolUsesById({ events }) {
|
|
3
|
+
export function dedupeToolUsesById({ events }, _optional) {
|
|
19
4
|
if (!events || events.length === 0)
|
|
20
5
|
return [];
|
|
21
6
|
const seen = new Set();
|
|
@@ -61,15 +46,21 @@ function directMcpToolName({ name }) {
|
|
|
61
46
|
* Antigravity's adapter-described wrappers require equal inner inputs but may arrive buffered.
|
|
62
47
|
*/
|
|
63
48
|
function wrapperCall({ event }, _options = {}) {
|
|
64
|
-
const wrapper = toolPresentation(event).wrapper;
|
|
49
|
+
const wrapper = event.bridgeCall ?? toolPresentation(event).wrapper;
|
|
50
|
+
const bridgeOnly = event.bridgeCall !== undefined;
|
|
51
|
+
const buffered = bridgeOnly ? event.bridgeCall?.buffered === true : wrapper !== undefined;
|
|
65
52
|
const call = wrapper ?? event;
|
|
66
53
|
if (!isDelegatedWrapperToolName({ name: call.name })) {
|
|
67
54
|
const name = wrapper ? wrapper.name : directMcpToolName({ name: call.name });
|
|
68
|
-
return name ? { name, input: call.input, matchInput: true, buffered
|
|
55
|
+
return name ? { name, input: call.input, matchInput: true, buffered, bridgeOnly } : undefined;
|
|
69
56
|
}
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
57
|
+
const inner = delegatedCall({ input: call.input });
|
|
58
|
+
return inner ? { ...inner, matchInput: wrapper !== undefined, buffered, bridgeOnly } : undefined;
|
|
59
|
+
}
|
|
60
|
+
/** The shared delegated gateway contract, independent of the CLI's envelope. Pure, O(1). */
|
|
61
|
+
function delegatedCall({ input }, _options = {}) {
|
|
62
|
+
const args = input !== null && typeof input === 'object' ? input : {};
|
|
63
|
+
return typeof args.toolId === 'string' && args.toolId ? { name: args.toolId, input: args.input } : undefined;
|
|
73
64
|
}
|
|
74
65
|
/**
|
|
75
66
|
* Drop each delegated or direct MCP wrapper call that the daemon also reported as its own canonical row,
|
|
@@ -88,9 +79,14 @@ function wrapperCall({ event }, _options = {}) {
|
|
|
88
79
|
* Antigravity buffers CLI receipts: its adapter-described wrappers match equal inner inputs
|
|
89
80
|
* anywhere in this assistant turn, in appearance order among equal keys. Pass only one turn's events;
|
|
90
81
|
* no state is retained across calls. Wrapper and bridge rows are each claimed at most once.
|
|
82
|
+
* Receipt and canonical failure states must match: a success cannot consume a failed row,
|
|
83
|
+
* and an identical successful or pending call cannot account for a refusal before the bridge,
|
|
84
|
+
* regardless of buffered receipt ordering.
|
|
91
85
|
* A legacy gateway folds onto the first LATER canonical `tool_use` whose name equals its `input.toolId`.
|
|
92
86
|
* A direct call folds onto the first LATER `X` row with structurally equal JSON input that arrives
|
|
93
|
-
* before the CLI's own result for it
|
|
87
|
+
* before the CLI's own result for it; failing that, once that result has arrived, onto the first
|
|
88
|
+
* unclaimed same-name row between the two, since argument normalization can change the bridge's
|
|
89
|
+
* input (see {@link findCanonicalRow}). No canonical row is claimed twice, and a folded wrapper's
|
|
94
90
|
* `tool_result` events go with it. A wrapper with no such canonical row (refused before the
|
|
95
91
|
* bridge, e.g. a read-only 403) stays, so its error is not hidden.
|
|
96
92
|
*
|
|
@@ -106,19 +102,24 @@ export function foldDelegatedWrapperCalls({ events }, _options = {}) {
|
|
|
106
102
|
return [];
|
|
107
103
|
const claimed = new Set();
|
|
108
104
|
const folded = new Set();
|
|
109
|
-
events.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
105
|
+
const failedResults = new Set(events.flatMap(event => event.kind === 'tool_result' && event.isError ? [event.toolUseId] : []));
|
|
106
|
+
// Equal inputs pair first across the whole turn, so a by-name pairing never takes the row an
|
|
107
|
+
// exact match needed; only then may a direct call pair by name inside its closed window.
|
|
108
|
+
for (const byName of [false, true]) {
|
|
109
|
+
events.forEach((event, i) => {
|
|
110
|
+
if (event.kind !== 'tool_use')
|
|
111
|
+
return;
|
|
112
|
+
const call = wrapperCall({ event });
|
|
113
|
+
// A replayed wrapper (same id) must not claim a second canonical row.
|
|
114
|
+
if (call === undefined || folded.has(event.id))
|
|
115
|
+
return;
|
|
116
|
+
const match = findCanonicalRow({ events, wrapper: event, wrapperIndex: i, call, claimed, failedResults }, { byName });
|
|
117
|
+
if (match === -1)
|
|
118
|
+
return;
|
|
119
|
+
claimed.add(match);
|
|
120
|
+
folded.add(event.id);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
122
123
|
if (folded.size === 0)
|
|
123
124
|
return events;
|
|
124
125
|
return events.filter((event) => !((event.kind === 'tool_use' && folded.has(event.id)) ||
|
|
@@ -127,29 +128,60 @@ export function foldDelegatedWrapperCalls({ events }, _options = {}) {
|
|
|
127
128
|
/**
|
|
128
129
|
* Index of the first unclaimed canonical row in the wrapper's matching window, or -1. Legacy gateways match
|
|
129
130
|
* on name alone; direct calls and adapter-described wrappers also require equal inner input.
|
|
131
|
+
*
|
|
132
|
+
* `byName` (the second pass) lets a direct `mcp__<server>__X` call pair with a same-name row whose
|
|
133
|
+
* input differs, but only inside a CLOSED window: after the call and before the CLI's own result for
|
|
134
|
+
* it. The MCP server normalizes arguments before the bridge sees them (an integer string read as the
|
|
135
|
+
* integer, an alias renamed, see `@jini-ai/mcp`'s `tool-arguments.ts`), so the bridge row's input is
|
|
136
|
+
* not always the CLI's (demo practice run, 2026-11-02: every such call showed twice). The CLI's result
|
|
137
|
+
* only returns after the bridge row its call caused, so a same-name row in that window is that call.
|
|
138
|
+
* An open window (no CLI result yet) and buffered or legacy wrappers never pair by name.
|
|
130
139
|
* @throws CanonicalJsonError if a direct call's input contains non-JSON values.
|
|
131
140
|
* @complexity O(n·s): n events, s canonical input serialization cost.
|
|
132
141
|
*/
|
|
133
|
-
function findCanonicalRow({ events, wrapper, wrapperIndex, call, claimed }) {
|
|
134
|
-
const
|
|
135
|
-
const
|
|
142
|
+
function findCanonicalRow({ events, wrapper, wrapperIndex, call, claimed, failedResults }, { byName = false } = {}) {
|
|
143
|
+
const [start, end, closed] = canonicalSearchWindow({ events, wrapper, wrapperIndex, call });
|
|
144
|
+
const match = matchRule({ call, byName, closed });
|
|
145
|
+
if (match === undefined)
|
|
146
|
+
return -1;
|
|
147
|
+
const inputKey = match.compareInput ? canonicalJson({ value: { input: call.input } }) : undefined;
|
|
148
|
+
const wrapperFailed = failedResults.has(wrapper.id);
|
|
136
149
|
for (let j = start; j < end; j += 1) {
|
|
137
150
|
const other = events[j];
|
|
138
|
-
if (claimed.has(j)
|
|
139
|
-
continue;
|
|
140
|
-
if (inputKey === undefined || canonicalJson({ value: { input: other.input } }) === inputKey)
|
|
151
|
+
if (!claimed.has(j) && isCandidateRow({ other, call, wrapperFailed, failedResults, inputKey }))
|
|
141
152
|
return j;
|
|
142
153
|
}
|
|
143
154
|
return -1;
|
|
144
155
|
}
|
|
156
|
+
/** How one pass matches `call`, or undefined when this pass must skip it. Pass two (`byName`) only
|
|
157
|
+
* revisits direct calls whose window is closed; everything else already had its chance. Pure; O(1). */
|
|
158
|
+
function matchRule({ call, byName, closed }, _options = {}) {
|
|
159
|
+
if (!byName)
|
|
160
|
+
return { compareInput: call.matchInput };
|
|
161
|
+
return closed && call.matchInput && !call.buffered ? { compareInput: false } : undefined;
|
|
162
|
+
}
|
|
163
|
+
/** Whether `other` is an unclaimed row's possible canonical twin: a same-name `tool_use` with the same
|
|
164
|
+
* outcome and, when `inputKey` is given, structurally equal input. Pure; O(s) input serialization. */
|
|
165
|
+
function isCandidateRow({ other, call, wrapperFailed, failedResults, inputKey }, _options = {}) {
|
|
166
|
+
if (other.kind !== 'tool_use' || other.name !== call.name)
|
|
167
|
+
return false;
|
|
168
|
+
// Adapter-proposed bare/native matches must be bridge rows, never independent CLI executions.
|
|
169
|
+
if (call.bridgeOnly && other.toolOrigin !== 'bridge')
|
|
170
|
+
return false;
|
|
171
|
+
// Outcome parity keeps successes from consuming failed rows and duplicating later refusals.
|
|
172
|
+
if (wrapperFailed !== failedResults.has(other.id))
|
|
173
|
+
return false;
|
|
174
|
+
return inputKey === undefined || canonicalJson({ value: { input: other.input } }) === inputKey;
|
|
175
|
+
}
|
|
145
176
|
/** Pure matching bounds within one turn; O(n) time, O(1) space for n events.
|
|
146
177
|
* Antigravity's buffered receipts cannot establish arrival/result boundaries. Direct MCP calls
|
|
147
178
|
* retain their later-only window so a completed/refused call cannot consume a separate retry.
|
|
179
|
+
* `closed` is true only when the CLI's own result ends the window (the by-name pass needs it).
|
|
148
180
|
*/
|
|
149
181
|
function canonicalSearchWindow({ events, wrapper, wrapperIndex, call }, _options = {}) {
|
|
150
182
|
if (call.buffered)
|
|
151
|
-
return [0, events.length];
|
|
183
|
+
return [0, events.length, false];
|
|
152
184
|
const resultIndex = !call.matchInput ? -1 : events.findIndex(other => other.kind === 'tool_result' && other.toolUseId === wrapper.id);
|
|
153
|
-
return
|
|
185
|
+
return resultIndex === -1 ? [wrapperIndex + 1, events.length, false] : [wrapperIndex + 1, resultIndex, true];
|
|
154
186
|
}
|
|
155
187
|
//# sourceMappingURL=tool-events.js.map
|
|
@@ -40,7 +40,7 @@ export declare function turnUsage({ events }: {
|
|
|
40
40
|
agentId?: string;
|
|
41
41
|
}): UsageEvent | undefined;
|
|
42
42
|
/** Derive per-turn charges and prefix totals from persisted events_json, for live and reloaded
|
|
43
|
-
* chats alike.
|
|
43
|
+
* chats alike. A single reply's duration is the message's elapsed run time, from send to finish.
|
|
44
44
|
* A message with mid-run replies also gets per-reply `segments`. O(e + m) for e events
|
|
45
45
|
* and m messages; no per-row transcript scans or I/O.
|
|
46
46
|
* @example conversationUsage({ messages: savedMessages }).get('assistant-2') */
|
package/dist/core/turn-usage.js
CHANGED
|
@@ -113,7 +113,7 @@ function addUsage({ total, usage }, _optional = {}) {
|
|
|
113
113
|
return next;
|
|
114
114
|
}
|
|
115
115
|
/** Derive per-turn charges and prefix totals from persisted events_json, for live and reloaded
|
|
116
|
-
* chats alike.
|
|
116
|
+
* chats alike. A single reply's duration is the message's elapsed run time, from send to finish.
|
|
117
117
|
* A message with mid-run replies also gets per-reply `segments`. O(e + m) for e events
|
|
118
118
|
* and m messages; no per-row transcript scans or I/O.
|
|
119
119
|
* @example conversationUsage({ messages: savedMessages }).get('assistant-2') */
|
|
@@ -157,9 +157,11 @@ function messageUsage({ message, sessions, totalCostUsd }, _optional = {}) {
|
|
|
157
157
|
}
|
|
158
158
|
return { usage, segments, totalCostUsd: prefixTotal };
|
|
159
159
|
}
|
|
160
|
-
/**
|
|
160
|
+
/** Time one reply from the person's send (`startedAt`) to the run's end, the same way for every CLI.
|
|
161
|
+
* A CLI's own reported duration leaves out its start-up wait (Claude Code said 42 s for a turn that took
|
|
162
|
+
* 46.5 s from send to finish), so it is only the fallback when the timestamps are missing or invalid. */
|
|
161
163
|
function fillRunDuration({ usage, message }, _optional = {}) {
|
|
162
|
-
if (
|
|
164
|
+
if (message.startedAt === undefined || message.endedAt === undefined)
|
|
163
165
|
return;
|
|
164
166
|
const durationMs = message.endedAt - message.startedAt;
|
|
165
167
|
if (Number.isFinite(durationMs) && durationMs >= 0)
|
|
@@ -72,9 +72,22 @@ export interface FrontendSessionBridge {
|
|
|
72
72
|
sessionId: string;
|
|
73
73
|
bindToken: string;
|
|
74
74
|
}>;
|
|
75
|
+
/**
|
|
76
|
+
* Ensure a connection and wait for its current attachment; an already live binding is reused.
|
|
77
|
+
* Call after successful host activation. Unlike `ready`, prior readiness cannot satisfy this wait.
|
|
78
|
+
* Cancellation rejects only this caller's wait; park/failure/close rejects all current waiters.
|
|
79
|
+
* @example await bridge.rebind({ signal }, {});
|
|
80
|
+
* @complexity O(1) per waiter; one shared stream, no automatic reconnect after park/failure.
|
|
81
|
+
*/
|
|
82
|
+
rebind(required?: {
|
|
83
|
+
signal?: AbortSignal;
|
|
84
|
+
}, optional?: Record<string, never>): Promise<{
|
|
85
|
+
sessionId: string;
|
|
86
|
+
bindToken: string;
|
|
87
|
+
}>;
|
|
75
88
|
/**
|
|
76
89
|
* The token for the attachment that is live *now*, or `undefined` before the first attach and
|
|
77
|
-
* after {@link FrontendSessionBridge.close}. Read it at run-start
|
|
90
|
+
* after park, suspension, failure or {@link FrontendSessionBridge.close}. Read it at run-start.
|
|
78
91
|
*/
|
|
79
92
|
bindToken(): string | undefined;
|
|
80
93
|
close(): void;
|
|
@@ -27,13 +27,13 @@ import { decodeSseFrames } from '@jini-ai/core/primitives';
|
|
|
27
27
|
* - **The bind token never leaves this module except to the host.** It arrives on the `attached`
|
|
28
28
|
* event and must ride to run-start; it deliberately does not appear in any URL (see
|
|
29
29
|
* `@jini-ai/daemon`'s `FrontendSessionHandle`).
|
|
30
|
-
* - **Read the bind token at send time, not once.**
|
|
31
|
-
* daemon restart, a sleeping laptop, or an ordinary network blip
|
|
30
|
+
* - **Read the bind token at send time, not once.** After a
|
|
31
|
+
* daemon restart, a sleeping laptop, or an ordinary network blip, each reattach mints a new
|
|
32
32
|
* session and a new token. A host that captured `ready`'s token keeps sending a dead one, every
|
|
33
33
|
* later run fails to bind, and the only trace is a daemon-side log line: the agent is simply told
|
|
34
34
|
* "no frontend is bound to this run". `ready` therefore answers "has it attached at least once";
|
|
35
35
|
* {@link FrontendSessionBridge.bindToken} answers "what is valid now", and only the latter
|
|
36
|
-
* belongs in a run request.
|
|
36
|
+
* belongs in a run request. Park/failure closes the transport; hosts explicitly rebind after wake.
|
|
37
37
|
*/
|
|
38
38
|
import { CHAT_CAPABILITIES } from '../../core/index.js';
|
|
39
39
|
import { PAGE_CAPABILITIES, executePageCapability } from '@jini-ai/agentic';
|
|
@@ -119,6 +119,8 @@ function readFrame(data) {
|
|
|
119
119
|
*/
|
|
120
120
|
export function createFrontendSessionBridge({ baseUrl: base, openStream, request, fetch, endpointPrefix = '/api/frontend-sessions', headers }, options = {}) {
|
|
121
121
|
const capabilities = claimedCapabilities(options);
|
|
122
|
+
// 0.6.x compatibility: fetch was optional for published hosts;
|
|
123
|
+
// remove when fetch injection becomes required in the next minor.
|
|
122
124
|
// The only caller (`respond`) never passes its own signal, so the deadline is the whole signal.
|
|
123
125
|
const send = request ?? (({ url, init, timeoutMs }) => (fetch ?? globalThis.fetch)(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }));
|
|
124
126
|
let sessionId;
|
|
@@ -138,6 +140,9 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
|
|
|
138
140
|
/** Invocation ids already run here, so a stream reconnect cannot double-click a button. */
|
|
139
141
|
const executed = new Set();
|
|
140
142
|
const pending = new Map();
|
|
143
|
+
const attachments = new Set();
|
|
144
|
+
let source;
|
|
145
|
+
let closed = false;
|
|
141
146
|
// A page driver owns one live screen. Serialize its observations, effect and acknowledgement;
|
|
142
147
|
// otherwise a second invocation could overwrite the first one's host-bound navigation plan.
|
|
143
148
|
let pageQueue = Promise.resolve();
|
|
@@ -148,7 +153,29 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
|
|
|
148
153
|
pending.clear();
|
|
149
154
|
};
|
|
150
155
|
const query = capabilities.map((id) => `capability=${encodeURIComponent(id)}`).join('&');
|
|
151
|
-
|
|
156
|
+
/** Invalidate before any reopen; queued and running page work cannot cross attachments.
|
|
157
|
+
* O(pending invocations + attachment waiters), with no retained settled waiters. */
|
|
158
|
+
function invalidate({ error }) {
|
|
159
|
+
abortPending();
|
|
160
|
+
pageQueue = Promise.resolve();
|
|
161
|
+
// The daemon deletes a session's bind token the instant that connection drops, so by the time
|
|
162
|
+
// this fires the token in hand is already gone from the registry. Holding it until the next
|
|
163
|
+
// `attached` frame leaves a window where `bindToken()` hands back something dead, and a run
|
|
164
|
+
// started in that window fails to bind — reported to the agent only as "no frontend is bound
|
|
165
|
+
// to this run", with the real reason (a reconnect it never saw) nowhere in view.
|
|
166
|
+
// Reporting no token is the honest answer for a surface that is, right now, not attached.
|
|
167
|
+
currentBindToken = undefined;
|
|
168
|
+
sessionId = undefined;
|
|
169
|
+
for (const waiter of attachments)
|
|
170
|
+
waiter.reject(error);
|
|
171
|
+
attachments.clear();
|
|
172
|
+
}
|
|
173
|
+
function disconnect({ error }) {
|
|
174
|
+
const previous = source;
|
|
175
|
+
source = undefined;
|
|
176
|
+
invalidate({ error });
|
|
177
|
+
previous?.close();
|
|
178
|
+
}
|
|
152
179
|
async function respond(invocationId, body) {
|
|
153
180
|
if (sessionId === undefined)
|
|
154
181
|
throw new Error('the surface is not attached yet');
|
|
@@ -169,7 +196,9 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
|
|
|
169
196
|
const args = { capabilityId: action.capabilityId, input };
|
|
170
197
|
if (options.pageApproval && !options.pageApproval.isDirect(args)) {
|
|
171
198
|
await respond(action.invocationId, { phase: 'approval-pending' });
|
|
199
|
+
signal.throwIfAborted();
|
|
172
200
|
const answer = await options.pageApproval.request({ ...args, input: structuredClone(input), signal });
|
|
201
|
+
signal.throwIfAborted();
|
|
173
202
|
// Report the decision before effects, including decline/expiry; the server audits it.
|
|
174
203
|
await respond(action.invocationId, { phase: 'execution-started', decision: answer.confirmed ? 'approved' : answer.reason });
|
|
175
204
|
if (!answer.confirmed)
|
|
@@ -197,6 +226,8 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
|
|
|
197
226
|
}
|
|
198
227
|
catch (error) {
|
|
199
228
|
pending.delete(action.invocationId);
|
|
229
|
+
if (controller.signal.aborted)
|
|
230
|
+
return;
|
|
200
231
|
await respondError(action.invocationId, errorMessage(error)).catch(options.onError ?? (() => undefined));
|
|
201
232
|
return;
|
|
202
233
|
}
|
|
@@ -207,6 +238,7 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
|
|
|
207
238
|
try {
|
|
208
239
|
signal.throwIfAborted();
|
|
209
240
|
const projected = await options.onBeforeResponse?.({ action, output });
|
|
241
|
+
signal.throwIfAborted();
|
|
210
242
|
const acceptedOutput = projected ?? output;
|
|
211
243
|
await respondSuccess(action.invocationId, acceptedOutput);
|
|
212
244
|
signal.throwIfAborted();
|
|
@@ -250,44 +282,60 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
|
|
|
250
282
|
else
|
|
251
283
|
void serveLocally(action, controller);
|
|
252
284
|
}
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
285
|
+
function connect() {
|
|
286
|
+
const current = openStream({ url: `${base}${endpointPrefix}/stream?${query}`, ...(headers ? { headers: headers() } : {}) });
|
|
287
|
+
source = current;
|
|
288
|
+
current.addEventListener('message', (event) => {
|
|
289
|
+
if (source !== current)
|
|
258
290
|
return;
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
291
|
+
const frame = readFrame(event.data);
|
|
292
|
+
switch (frame.kind) {
|
|
293
|
+
case 'cancelled':
|
|
294
|
+
pending.get(frame.invocationId)?.abort();
|
|
295
|
+
return;
|
|
296
|
+
case 'attached':
|
|
297
|
+
abortPending();
|
|
298
|
+
sessionId = frame.sessionId;
|
|
299
|
+
currentBindToken = frame.bindToken;
|
|
300
|
+
// A no-op after the first attach — which is exactly why `currentBindToken` exists.
|
|
301
|
+
resolveReady({ sessionId, bindToken: currentBindToken });
|
|
302
|
+
for (const waiter of attachments)
|
|
303
|
+
waiter.resolve({ sessionId, bindToken: currentBindToken });
|
|
304
|
+
attachments.clear();
|
|
305
|
+
return;
|
|
306
|
+
case 'refused': {
|
|
307
|
+
const error = new Error(frame.message);
|
|
308
|
+
rejectReady(error);
|
|
309
|
+
disconnect({ error });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
case 'malformed':
|
|
313
|
+
options.onError?.(frame.error);
|
|
314
|
+
return;
|
|
315
|
+
case 'invocation':
|
|
316
|
+
handleInvocation(frame.action);
|
|
317
|
+
return;
|
|
318
|
+
case 'ignored':
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
current.addEventListener('parked', () => {
|
|
323
|
+
if (source === current)
|
|
324
|
+
disconnect({ error: new Error('the surface is parked') });
|
|
325
|
+
});
|
|
326
|
+
// Intentional visibility pauses keep their transport owner, which reopens on visibility/activity.
|
|
327
|
+
current.addEventListener('suspended', () => {
|
|
328
|
+
if (source === current)
|
|
329
|
+
invalidate({ error: new Error('the surface is suspended') });
|
|
330
|
+
});
|
|
331
|
+
current.addEventListener('error', (event) => {
|
|
332
|
+
if (source !== current)
|
|
276
333
|
return;
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
// this fires the token in hand is already gone from the registry. Holding it until the next
|
|
283
|
-
// `attached` frame leaves a window where `bindToken()` hands back something dead, and a run
|
|
284
|
-
// started in that window fails to bind — reported to the agent only as "no frontend is bound
|
|
285
|
-
// to this run", with the real reason (a reconnect it never saw) nowhere in view.
|
|
286
|
-
// Reporting no token is the honest answer for a surface that is, right now, not attached.
|
|
287
|
-
currentBindToken = undefined;
|
|
288
|
-
sessionId = undefined;
|
|
289
|
-
options.onError?.(event);
|
|
290
|
-
});
|
|
334
|
+
disconnect({ error: new Error('the frontend stream failed') });
|
|
335
|
+
options.onError?.(event);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
connect();
|
|
291
339
|
const bridgeAccess = {
|
|
292
340
|
subscribe(onAction) {
|
|
293
341
|
chatListeners.add(onAction);
|
|
@@ -301,21 +349,46 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
|
|
|
301
349
|
bridgeAccess,
|
|
302
350
|
ready,
|
|
303
351
|
bindToken: () => currentBindToken,
|
|
352
|
+
async rebind({ signal } = {}, _optional = {}) {
|
|
353
|
+
signal?.throwIfAborted();
|
|
354
|
+
if (closed)
|
|
355
|
+
throw new Error('the frontend bridge is closed');
|
|
356
|
+
if (sessionId !== undefined && currentBindToken !== undefined)
|
|
357
|
+
return { sessionId, bindToken: currentBindToken };
|
|
358
|
+
return new Promise((resolve, reject) => {
|
|
359
|
+
const cleanup = () => { attachments.delete(waiter); signal?.removeEventListener('abort', abort); };
|
|
360
|
+
const waiter = {
|
|
361
|
+
resolve: (value) => { cleanup(); resolve(value); },
|
|
362
|
+
reject: (error) => { cleanup(); reject(error); },
|
|
363
|
+
};
|
|
364
|
+
const abort = () => waiter.reject(signal?.reason);
|
|
365
|
+
attachments.add(waiter);
|
|
366
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
367
|
+
if (!source) {
|
|
368
|
+
try {
|
|
369
|
+
connect();
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
disconnect({ error });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
},
|
|
304
377
|
close() {
|
|
305
|
-
|
|
306
|
-
source.close();
|
|
307
|
-
chatListeners.clear();
|
|
378
|
+
closed = true;
|
|
308
379
|
// A closed bridge serves nothing, so binding a run to it would strand that run rather than
|
|
309
380
|
// fail it — this is what stops a torn-down surface (a StrictMode remount, a route change)
|
|
310
381
|
// from handing its dead token to the next run.
|
|
311
|
-
|
|
312
|
-
|
|
382
|
+
disconnect({ error: new Error('the frontend bridge is closed') });
|
|
383
|
+
chatListeners.clear();
|
|
313
384
|
},
|
|
314
385
|
};
|
|
315
386
|
}
|
|
316
387
|
/** Authenticated streaming replacement for EventSource when credentials belong in headers.
|
|
317
388
|
* Uses the shared SSE decoder; reconnect creates a new attachment and clears the old token.
|
|
318
389
|
* O(stream bytes), bounded frame buffer; close aborts fetch/readers and the reconnect timer. */
|
|
390
|
+
// 0.6.x compatibility: existing hosts may omit fetch;
|
|
391
|
+
// remove when fetch injection becomes required in the next minor.
|
|
319
392
|
export function openFetchFrontendStream({ url, headers }, { fetch: send = globalThis.fetch, reconnectMs = 1000 } = {}) {
|
|
320
393
|
const events = new EventTarget();
|
|
321
394
|
const abort = new AbortController();
|
|
@@ -331,14 +404,14 @@ export function openFetchFrontendStream({ url, headers }, { fetch: send = global
|
|
|
331
404
|
for await (const frame of decodeSseFrames({ source: streamChunks(response.body) }, { maxBufferChars: 128_000 })) {
|
|
332
405
|
delay = reconnectMs; // A delivered event proves the connection recovered.
|
|
333
406
|
if (!abort.signal.aborted)
|
|
334
|
-
events.dispatchEvent(new MessageEvent('message', { data: frame.data }));
|
|
407
|
+
events.dispatchEvent(new MessageEvent(frame.event === 'parked' ? 'parked' : 'message', { data: frame.data }));
|
|
335
408
|
}
|
|
336
409
|
}
|
|
337
410
|
catch { /* The stream error has one value-free notification below. */ }
|
|
338
411
|
if (abort.signal.aborted)
|
|
339
412
|
return;
|
|
340
413
|
events.dispatchEvent(new Event('error'));
|
|
341
|
-
if (retryable) {
|
|
414
|
+
if (retryable && !abort.signal.aborted) {
|
|
342
415
|
retry = setTimeout(() => { void connect(); }, delay);
|
|
343
416
|
delay = Math.min(delay * 2, 30_000);
|
|
344
417
|
}
|
|
@@ -30,9 +30,9 @@ export interface ComposerProps {
|
|
|
30
30
|
/** Cancels the in-flight run. Required when `running` is true; ignored otherwise. */
|
|
31
31
|
onCancel?: () => void;
|
|
32
32
|
/**
|
|
33
|
-
* Cmd/Ctrl+Enter:
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
* Cmd/Ctrl+Enter: optional host send shortcut. ChatPane shares the ordinary mid-run delivery
|
|
34
|
+
* path so sending keeps the current run going. Without this callback the modifier falls
|
|
35
|
+
* through to ordinary Enter, so a host that has not opted in keeps its previous behavior.
|
|
36
36
|
*/
|
|
37
37
|
onInterrupt?: () => void;
|
|
38
38
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { formatErrorForLog } from '@jini-ai/core';
|
|
2
3
|
/**
|
|
3
4
|
* @module Composer
|
|
4
5
|
*
|
|
@@ -21,7 +22,7 @@ import { AttachmentTray } from './AttachmentTray.js';
|
|
|
21
22
|
import { ComposerDiscoveryMenu, ComposerSlashMenu } from './ComposerDiscovery.js';
|
|
22
23
|
import { appendComposerDiscovery, composerDiscoveryMenuPosition, composerSlashMenuPosition, filterComposerDiscovery, parseComposerSlashQuery, replaceComposerSlashTrigger, resolveComposerSlashInvocation, resolveComposerSlashKeyAction, } from './composer-discovery.js';
|
|
23
24
|
function reportComposerHostEffectFailure(effectName, error) {
|
|
24
|
-
console.error(`[@jini-ai/chat] Composer ${effectName} host effect failed:`, error);
|
|
25
|
+
console.error(`[@jini-ai/chat] Composer ${effectName} host effect failed:`, formatErrorForLog({ error }));
|
|
25
26
|
}
|
|
26
27
|
/**
|
|
27
28
|
* Invokes host code synchronously, then observes either its synchronous result or async failure.
|
|
@@ -272,9 +273,9 @@ export function Composer({ composer, onSend, disabled = false, sendDisabled = fa
|
|
|
272
273
|
function submitFromEnter(event) {
|
|
273
274
|
if (disabled || !composer.canSubmit)
|
|
274
275
|
return;
|
|
275
|
-
// Cmd/Ctrl+Enter
|
|
276
|
-
// `sendDisabled` is deliberately NOT consulted
|
|
277
|
-
//
|
|
276
|
+
// Cmd/Ctrl+Enter uses the host shortcut; ChatPane routes it through the ordinary send guards.
|
|
277
|
+
// `sendDisabled` is deliberately NOT consulted here: hosts can offer a shortcut while a run
|
|
278
|
+
// itself blocks ordinary sends. ChatPane queues or delivers live without cancelling the run.
|
|
278
279
|
if ((event.metaKey || event.ctrlKey) && onInterrupt) {
|
|
279
280
|
onInterrupt();
|
|
280
281
|
return;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { formatErrorForLog } from '@jini-ai/core';
|
|
2
3
|
/**
|
|
3
4
|
* @module ConversationList
|
|
4
5
|
*
|
|
@@ -33,7 +34,7 @@ import { ConversationTitleEditor } from './ConversationTitleEditor.js';
|
|
|
33
34
|
*/
|
|
34
35
|
const DOUBLE_CLICK_GRACE_MS = 500;
|
|
35
36
|
function reportConversationListHostEffectFailure(effectName, error) {
|
|
36
|
-
console.error(`[@jini-ai/chat] ConversationList ${effectName} host effect failed:`, error);
|
|
37
|
+
console.error(`[@jini-ai/chat] ConversationList ${effectName} host effect failed:`, formatErrorForLog({ error }));
|
|
37
38
|
}
|
|
38
39
|
/** Invokes a host callback and reports a synchronous throw or async rejection without leaving it unhandled. */
|
|
39
40
|
function runConversationListHostEffect(effectName, effect) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { formatErrorForLog } from '@jini-ai/core';
|
|
2
3
|
import { useRef, useState } from 'react';
|
|
3
4
|
import { MAX_CONVERSATION_TITLE_LENGTH } from '../../core/conversation-title.js';
|
|
4
5
|
import { useT } from '../hooks/context.js';
|
|
@@ -28,7 +29,7 @@ export function ConversationTitleEditor({ title, onCommit, onCancel, className =
|
|
|
28
29
|
await onCommit(next);
|
|
29
30
|
}
|
|
30
31
|
catch (error) {
|
|
31
|
-
console.error('[@jini-ai/chat] ConversationTitleEditor onCommit host effect failed:', error);
|
|
32
|
+
console.error('[@jini-ai/chat] ConversationTitleEditor onCommit host effect failed:', formatErrorForLog({ error }));
|
|
32
33
|
}
|
|
33
34
|
};
|
|
34
35
|
return _jsx("input", { autoFocus: true, className: className, "aria-label": t('Rename chat'), "data-testid": testId, maxLength: MAX_CONVERSATION_TITLE_LENGTH, value: draft, onChange: (event) => setDraft(event.currentTarget.value), onBlur: () => { void finish(); }, onKeyDown: (event) => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { formatErrorForLog } from '@jini-ai/core';
|
|
2
3
|
/**
|
|
3
4
|
* @module ExtEventErrorBoundary
|
|
4
5
|
*
|
|
@@ -25,7 +26,7 @@ export class ExtEventErrorBoundary extends Component {
|
|
|
25
26
|
// React already surfaces this in the dev console via its own error-boundary reporting; this
|
|
26
27
|
// adds the ext-event `name` React's generic report has no way to know, and gives a host a
|
|
27
28
|
// hook to route it into its own error reporting.
|
|
28
|
-
console.error(`[ExtEventErrorBoundary] ext event "${this.props.name}" failed to render:`, error);
|
|
29
|
+
console.error(`[ExtEventErrorBoundary] ext event "${this.props.name}" failed to render:`, formatErrorForLog({ error }));
|
|
29
30
|
this.props.onError?.(error, info);
|
|
30
31
|
}
|
|
31
32
|
render() {
|
|
@@ -71,6 +71,7 @@ import { useT } from '../hooks/context.js';
|
|
|
71
71
|
import { useSurfaceExpiry } from '../hooks/useSurfaceExpiry.js';
|
|
72
72
|
import { closedSurfaceLabel, reportDeliveredAnswer } from './mcp-ui-surface-state.js';
|
|
73
73
|
import { surfaceQuestionInput } from './mcp-ui-question-input.js';
|
|
74
|
+
import { useSurfaceRecovery } from '../hooks/useSurfaceRecovery.js';
|
|
74
75
|
/** The `kind: 'ext'` event name this renderer claims. */
|
|
75
76
|
export const MCP_UI_EXT_EVENT_NAME = 'mcp-ui';
|
|
76
77
|
/** Collapses the event stream to the newest resource per `ui://` URI, in first-appearance order. */
|
|
@@ -147,18 +148,24 @@ function surfaceInteraction({ resource, answered, onToolCall, onAnswered, onAnsw
|
|
|
147
148
|
/** Registered against `ext-event-renderer-registry.ts`'s `'mcp-ui'` name — see module doc. */
|
|
148
149
|
export function McpUiSurfaceCard({ events, sandboxProxyUrl, onToolCall, onOpenLink, maxHeight, call, runStreaming, expiryClock, answered, onAnswered, onAnswerText, answerText, answerRecorded }) {
|
|
149
150
|
const t = useT();
|
|
150
|
-
const
|
|
151
|
+
const originalResources = useMemo(() => latestResourcesByUri(events), [events]);
|
|
152
|
+
const recovery = useSurfaceRecovery({ resource: originalResources.length === 1 ? originalResources[0] : undefined,
|
|
153
|
+
call, runStreaming, onToolCall });
|
|
154
|
+
const resources = recovery.restored && recovery.resource ? [recovery.resource] : originalResources;
|
|
151
155
|
// One card per slot is the registered shape (`mcpUiSurfaceSlotKey`), so `call` belongs to exactly
|
|
152
156
|
// that card. A group holding several URIs has no single call to judge them by, and stays live.
|
|
153
157
|
const single = resources.length === 1 ? resources[0] : undefined;
|
|
154
|
-
const
|
|
155
|
-
const
|
|
158
|
+
const currentEvents = recovery.restored && recovery.resource ? [recovery.resource] : events;
|
|
159
|
+
const currentCall = recovery.restored ? undefined : call;
|
|
160
|
+
const { displayCall, input, standalone } = useSurfaceQuestionCall({ events: currentEvents, call: currentCall });
|
|
161
|
+
const { expiresAtMs, toolCall } = surfaceInteraction({ resource: single, answered, onToolCall: recovery.onToolCall, onAnswered, onAnswerText, call: displayCall });
|
|
156
162
|
const expiry = useSurfaceExpiry({ expiresAtMs }, expiryClock === undefined ? {} : { clock: expiryClock });
|
|
157
163
|
const closed = single === undefined
|
|
158
164
|
? undefined
|
|
159
|
-
: closedSurfaceLabel({ t, call, runStreaming
|
|
165
|
+
: closedSurfaceLabel({ t, call: currentCall, runStreaming: recovery.live || runStreaming,
|
|
166
|
+
documentsForUri: currentEvents.length, expired: expiry?.expired === true });
|
|
160
167
|
// A completed call answered the original document; a replacement outcome or retry owns its view.
|
|
161
|
-
const answer = (
|
|
168
|
+
const answer = (currentEvents.length === 1 ? questionAnswerFromResult({ result: currentCall?.result, input }) : undefined) ?? answerText;
|
|
162
169
|
return _jsx(SurfaceCardContent, { resources: resources, single: single, answerRecorded: answerRecorded, answer: answer, closed: closed, call: displayCall, standalone: standalone, t: t, sandboxProxyUrl: sandboxProxyUrl, toolCall: toolCall, onOpenLink: onOpenLink, maxHeight: maxHeight, expiry: expiry });
|
|
163
170
|
}
|
|
164
171
|
/** A standalone form's call may lack the question; its current document supplies the title and
|
|
@@ -38,7 +38,8 @@ export const MessageList = memo(function MessageList({ messages: sourceMessages,
|
|
|
38
38
|
message,
|
|
39
39
|
isLast: message.id === messages[messages.length - 1]?.id,
|
|
40
40
|
// A reply's start is accepted together with its user turn (`inputPending` clears then).
|
|
41
|
-
|
|
41
|
+
// A run id also proves acceptance after reload, when a legacy pending flag can survive.
|
|
42
|
+
sending: message.runId === undefined && messages[index - 1]?.inputPending === true,
|
|
42
43
|
isStreaming,
|
|
43
44
|
usageSummary: usageByMessage.get(message.id),
|
|
44
45
|
}, rowProps)), (pendingPrompts ?? (pendingPrompt ? [pendingPrompt] : [])).map((prompt, index) => (_jsx(QueuedMessage, { prompt: prompt, renderAttachment: rowProps.renderAttachment }, prompt.id ?? index))), awayFromLatest && _jsx(JumpToLatest, { onJump: jumpToLatest })] }));
|