@jini-ai/chat 0.6.22 → 0.6.23
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 +4 -0
- package/dist/core/assistant-client/ag-ui-transport.js +9 -3
- package/dist/core/assistant-client/transport.js +15 -8
- package/dist/core/durable-runs/ports.d.ts +4 -1
- package/dist/embed-widget/browser/frontend-session-bridge.d.ts +18 -0
- package/dist/embed-widget/browser/frontend-session-bridge.js +27 -0
- package/dist/react/components/MessageRow.js +12 -5
- package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +1 -1
- package/dist/react/features/chat-pane/components/ChatPane.js +1 -0
- package/dist/react/features/chat-pane/create-frontend-request-caller.d.ts +23 -0
- package/dist/react/features/chat-pane/create-frontend-request-caller.js +56 -0
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +2 -0
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +8 -0
- package/dist/react/features/chat-pane/surface-recovery-stream.d.ts +11 -3
- package/dist/react/features/chat-pane/surface-recovery-stream.js +28 -18
- package/dist/react/features/chat-pane/types.d.ts +3 -0
- package/dist/react/index.d.ts +4 -2
- package/dist/react/index.js +2 -1
- package/dist/server/durable-runs/recover.js +43 -6
- package/package.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { EventType } from "@ag-ui/core";
|
|
2
2
|
import { createAgUiToAgentTranslationState, translateAgUiEventToAgentEvent } from "../ag-ui/entry.js";
|
|
3
|
-
/** Only the originating attachment can authorize page execution. Read its token for every
|
|
4
|
-
* caller-supplied tool definitions never authorize or execute a transcript event.
|
|
3
|
+
/** Only the originating attachment can authorize page execution. Read its token and tab for every
|
|
4
|
+
* turn; caller-supplied tool definitions never authorize or execute a transcript event.
|
|
5
|
+
* @complexity O(1) time and space.
|
|
6
|
+
* @overallScore 100 */
|
|
5
7
|
function frontendBinding({ input }, _options = {}) {
|
|
6
8
|
const token = input.context?.["frontendBindToken"];
|
|
7
|
-
|
|
9
|
+
const tabId = input.context?.["frontendTabId"];
|
|
10
|
+
return {
|
|
11
|
+
...(typeof token === "string" && token.length > 0 ? { frontendBindToken: token } : {}),
|
|
12
|
+
...(typeof tabId === "string" && tabId.length > 0 ? { frontendTabId: tabId } : {}),
|
|
13
|
+
};
|
|
8
14
|
}
|
|
9
15
|
/** Bind the official client without adding its dependency to every chat consumer. */
|
|
10
16
|
export function createAgUiRunClient({ ports }, _options = {}) {
|
|
@@ -458,18 +458,25 @@ export function createAssistantTransportClient({ ports }, _options = {}) {
|
|
|
458
458
|
* and a spread would let any future `runContext` key silently shadow it — an identity field being
|
|
459
459
|
* overwritten by a UI prop is not a failure mode worth leaving open to save one line.
|
|
460
460
|
*
|
|
461
|
-
*
|
|
461
|
+
* The tab id accompanies the token so the host can verify the session owner before binding.
|
|
462
|
+
* @complexity O(1) time and space.
|
|
463
|
+
* @overallScore 100
|
|
464
|
+
* Returns `{}` (omitting the fields entirely) when absent, which is a normal state, not an error:
|
|
462
465
|
* the daemon treats a run with no bind token as one with no screen to drive
|
|
463
466
|
* (`agent-daemon-server.ts`).
|
|
464
467
|
*/
|
|
465
|
-
function
|
|
468
|
+
function frontendBindingFields(input) {
|
|
466
469
|
const frontendBindToken = input.context?.["frontendBindToken"];
|
|
467
|
-
|
|
470
|
+
const frontendTabId = input.context?.["frontendTabId"];
|
|
471
|
+
return {
|
|
472
|
+
...(typeof frontendBindToken === "string" && frontendBindToken.length > 0 ? { frontendBindToken } : {}),
|
|
473
|
+
...(typeof frontendTabId === "string" && frontendTabId.length > 0 ? { frontendTabId } : {}),
|
|
474
|
+
};
|
|
468
475
|
}
|
|
469
476
|
/**
|
|
470
477
|
* The Local CLI picker's live model selection, from `ChatPane`'s `runContext` prop
|
|
471
478
|
* (`AssistantDock.tsx`'s `resolveRunContext`). Same "read by name, not spread" reasoning as
|
|
472
|
-
* {@link
|
|
479
|
+
* {@link frontendBindingFields} above, and the same "omit when absent" convention. Forwarded as an
|
|
473
480
|
* opaque string — `agent-daemon-server.ts` forwards it the same way, and `AgentExecutor.run()`'s
|
|
474
481
|
* def-level `buildArgs` is what decides what an absent or `'default'` value means for a given CLI
|
|
475
482
|
* (`@jini-ai/agent-runtime`'s `models.ts`/`resolveModelForAgent`).
|
|
@@ -498,7 +505,7 @@ export function createAssistantTransportClient({ ports }, _options = {}) {
|
|
|
498
505
|
* re-validated paths via `AttachmentStore.claim()`. Nothing on this side of the wire is trusted; the
|
|
499
506
|
* id is inert until the daemon claims it.
|
|
500
507
|
*
|
|
501
|
-
* Returns `{}`, same convention as {@link
|
|
508
|
+
* Returns `{}`, same convention as {@link frontendBindingFields} above — a run with no attachments
|
|
502
509
|
* is the overwhelmingly common case and should not carry a key for it.
|
|
503
510
|
*/
|
|
504
511
|
function attachmentIdsField(input) {
|
|
@@ -509,7 +516,7 @@ export function createAssistantTransportClient({ ports }, _options = {}) {
|
|
|
509
516
|
/**
|
|
510
517
|
* Opaque Agent Plugin ids (`plugin.json`'s own `name`, e.g. `"ui-ux-design"`) the operator has
|
|
511
518
|
* pinned as composer chips — `AssistantDock.tsx`'s `useSelectedAgentPlugins`, threaded here via
|
|
512
|
-
* `ChatPane`'s `runContext` prop the same way {@link
|
|
519
|
+
* `ChatPane`'s `runContext` prop the same way {@link frontendBindingFields}/{@link modelField}
|
|
513
520
|
* above already are. Same "read by name, not spread" and "omit when absent" conventions as those
|
|
514
521
|
* two fields: this is not the plugin's CONTENT, only a reference to it —
|
|
515
522
|
* `agent-daemon-server.ts`'s `onStarted` is where a ref gets resolved against the real installed
|
|
@@ -528,7 +535,7 @@ export function createAssistantTransportClient({ ports }, _options = {}) {
|
|
|
528
535
|
}
|
|
529
536
|
/**
|
|
530
537
|
* The active conversation id, from `ChatPane`'s `runContext` prop (`AssistantDock.tsx`'s
|
|
531
|
-
* `chats.activeId`, the same `useRunContext` seam {@link
|
|
538
|
+
* `chats.activeId`, the same `useRunContext` seam {@link frontendBindingFields}/{@link modelField}/
|
|
532
539
|
* {@link pluginRefIdsField} above already ride). Lets `agent-daemon-server.ts`'s `onStarted` resume
|
|
533
540
|
* this conversation's agent-CLI session across turns instead of spawning cold every time
|
|
534
541
|
* (`server/inbound/assistant/agent-session-resume.ts`) — see that file's own doc. Returns `{}`, same
|
|
@@ -581,7 +588,7 @@ export function createAssistantTransportClient({ ports }, _options = {}) {
|
|
|
581
588
|
// daemon JSON boundary. Replaying an old signal reports another paste on every later turn.
|
|
582
589
|
...(newestUserMessage({ history: input.history }, {})?.secretRedaction?.secretRedacted ? { secretRedacted: true } : {}),
|
|
583
590
|
assistantMessageId: input.assistantMessageId,
|
|
584
|
-
...
|
|
591
|
+
...frontendBindingFields(input),
|
|
585
592
|
...modelField(input),
|
|
586
593
|
...reasoningField(input),
|
|
587
594
|
...attachmentIdsField(input),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AgentEvent, ChatMessage } from "../index.js";
|
|
2
|
-
import type { RunRef, RunSettlement } from "./run-contracts.js";
|
|
2
|
+
import type { RunProgress, RunRef, RunSettlement } from "./run-contracts.js";
|
|
3
3
|
export interface AcceptedRunRequest {
|
|
4
4
|
readonly agentId?: string;
|
|
5
5
|
readonly contextRef: string;
|
|
@@ -122,6 +122,9 @@ export interface RecoveryPorts {
|
|
|
122
122
|
run: DurableRun;
|
|
123
123
|
request: AcceptedRunRequest;
|
|
124
124
|
}, optional: {}) => Promise<void>;
|
|
125
|
+
/** Save a launch-response diagnostic without declaring the uncertain attempt dead. Uses the
|
|
126
|
+
* host's existing fenced ledger checkpoint; no new storage format or polling is required. */
|
|
127
|
+
readonly checkpoint?: (required: RunProgress, optional: {}) => Promise<boolean>;
|
|
125
128
|
readonly cancelAttempt: (required: DurableRun, optional: {}) => Promise<void>;
|
|
126
129
|
readonly verifyChildDead: (required: {
|
|
127
130
|
pid: number;
|
|
@@ -109,4 +109,22 @@ export declare function openFetchFrontendStream({ url, headers }: {
|
|
|
109
109
|
fetch?: typeof globalThis.fetch;
|
|
110
110
|
reconnectMs?: number;
|
|
111
111
|
}): Pick<EventSource, 'addEventListener' | 'close'>;
|
|
112
|
+
export interface FrontendNavigationGuard {
|
|
113
|
+
noteNavigation(): void;
|
|
114
|
+
preparePageInput(required: {
|
|
115
|
+
action: ChatPaneAgentToolAction;
|
|
116
|
+
}): Record<string, unknown>;
|
|
117
|
+
onBeforeResponse(required: {
|
|
118
|
+
action: ChatPaneAgentToolAction;
|
|
119
|
+
output: unknown;
|
|
120
|
+
}): unknown;
|
|
121
|
+
}
|
|
122
|
+
/** Fence a protected browser effect to the exact view in which its human card was proposed.
|
|
123
|
+
* Hosts call noteNavigation synchronously for every navigation, including a return to an old URL.
|
|
124
|
+
* Internal metadata is checked and removed before the page capability's public schema sees it.
|
|
125
|
+
* @returns Existing bridge callbacks plus the host-owned navigation notification.
|
|
126
|
+
* @complexity O(input keys) time/space per prepared action; O(1) retained revision counter.
|
|
127
|
+
* @overallScore 100
|
|
128
|
+
*/
|
|
129
|
+
export declare function createFrontendNavigationGuard(_required: Record<string, never>, _optional?: Record<string, never>): FrontendNavigationGuard;
|
|
112
130
|
//# sourceMappingURL=frontend-session-bridge.d.ts.map
|
|
@@ -454,4 +454,31 @@ async function assertFrontendResponse({ response, invocationId, body, scoped, re
|
|
|
454
454
|
throw new Error('Page invocation expired or was cancelled; no action was taken');
|
|
455
455
|
}
|
|
456
456
|
}
|
|
457
|
+
/** Fence a protected browser effect to the exact view in which its human card was proposed.
|
|
458
|
+
* Hosts call noteNavigation synchronously for every navigation, including a return to an old URL.
|
|
459
|
+
* Internal metadata is checked and removed before the page capability's public schema sees it.
|
|
460
|
+
* @returns Existing bridge callbacks plus the host-owned navigation notification.
|
|
461
|
+
* @complexity O(input keys) time/space per prepared action; O(1) retained revision counter.
|
|
462
|
+
* @overallScore 100
|
|
463
|
+
*/
|
|
464
|
+
export function createFrontendNavigationGuard(_required, _optional = {}) {
|
|
465
|
+
let revision = 0;
|
|
466
|
+
return {
|
|
467
|
+
noteNavigation: () => { revision += 1; },
|
|
468
|
+
preparePageInput: ({ action }) => {
|
|
469
|
+
const { __frontendNavigationRevision: expected, ...input } = action.input;
|
|
470
|
+
if (Object.hasOwn(action.input, '__frontendNavigationRevision') && (!Number.isSafeInteger(expected) || expected !== revision)) {
|
|
471
|
+
throw new Error('The page changed while confirmation was pending; propose this action again.');
|
|
472
|
+
}
|
|
473
|
+
return input;
|
|
474
|
+
},
|
|
475
|
+
onBeforeResponse: ({ action, output }) => {
|
|
476
|
+
if (action.capabilityId !== 'page.find_elements')
|
|
477
|
+
return output;
|
|
478
|
+
if (output === null || typeof output !== 'object' || Array.isArray(output))
|
|
479
|
+
return output;
|
|
480
|
+
return { ...output, navigationRevision: revision };
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
}
|
|
457
484
|
//# sourceMappingURL=frontend-session-bridge.js.map
|
|
@@ -187,16 +187,23 @@ function UserMessageRow({ message, renderAttachment }) {
|
|
|
187
187
|
const gallery = useAttachmentGallery({ attachments: message.attachments ?? NO_ATTACHMENTS });
|
|
188
188
|
return (_jsxs("div", { className: "jini-message jini-message-user", "data-message-id": message.id, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A message from the user", children: [(message.attachments?.length || message.folders?.length) ? (_jsxs("div", { className: "jini-message-attachments", children: [_jsx(FolderChips, { folders: message.folders }), (message.attachments ?? []).map((a) => (_jsx(SentAttachmentButton, { attachment: a, onOpen: gallery.open, renderAttachment: renderAttachment }, a.path)))] })) : null, _jsx("div", { className: "jini-message-content", children: message.content }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: message.content, label: t('Copy message') }) }), gallery.opened ? (_jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation })) : null] }));
|
|
189
189
|
}
|
|
190
|
+
// A validated extension may already show the explanation. Keep the generic fallback when its
|
|
191
|
+
// renderer is absent or refuses the payload, so malformed history never hides a failed turn.
|
|
192
|
+
function assistantTerminalNotice({ message, runStreaming, runSucceeded, extGroups, registry }, _options = {}) {
|
|
193
|
+
const extensionExplainsFailure = !runStreaming && !runSucceeded && extGroups.some(group => registry.hasTerminalNotice(group));
|
|
194
|
+
return extensionExplainsFailure ? null : terminalMessageNotice({ message }, {});
|
|
195
|
+
}
|
|
196
|
+
/** Only a completed reply without footer usage can own the final inline usage label. */
|
|
197
|
+
function lastFinishedReplyIndex({ runInProgress, usage }, _options = {}) {
|
|
198
|
+
return !runInProgress && !usage.footer ? usage.replies.findLastIndex(value => value !== undefined) : -1;
|
|
199
|
+
}
|
|
190
200
|
function AssistantMessageRow({ showAccessNotice = true, message, usageSummary, showUsageTotal, showUsageLine, toolCallDisplay, showThinking, runStreaming, runSending, sendingLabel, runSucceeded, questionFormInteractive, questionFormSubmittedAnswers, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, }) {
|
|
191
201
|
const registry = useExtEventRendererRegistry();
|
|
192
202
|
const messageUsage = useMessageUsage({ message, ...(usageSummary === undefined ? {} : { summary: usageSummary }) });
|
|
193
203
|
const timeline = useToolTimeline({ events: message.events }, { runStreaming, runSucceeded });
|
|
194
204
|
const extGroups = useExtEventGroups({ events: message.events }, { slotOf: (name, data) => registry.slot({ name, data }) });
|
|
195
205
|
const answeredCards = useAnsweredCards({ slotOf: registry.slot, events: message.events ?? [], groups: extGroups, runStreaming, runStatus: message.runStatus });
|
|
196
|
-
|
|
197
|
-
// renderer is absent or refuses the payload, so malformed history never hides a failed turn.
|
|
198
|
-
const extensionExplainsFailure = !runStreaming && !runSucceeded && extGroups.some(group => registry.hasTerminalNotice(group));
|
|
199
|
-
const terminalNotice = extensionExplainsFailure ? null : terminalMessageNotice({ message }, {});
|
|
206
|
+
const terminalNotice = assistantTerminalNotice({ message, runStreaming, runSucceeded, extGroups, registry });
|
|
200
207
|
const visibleContent = stripArtifact({ content: message.content });
|
|
201
208
|
const displayBlocks = messageBlocks({ message, rows: timeline.rows, registry }, { toolCallDisplay, includeThinking: showThinking });
|
|
202
209
|
// Artifact stripping trims/replaces text, invalidating the event offsets for rendering. Keep the
|
|
@@ -209,7 +216,7 @@ function AssistantMessageRow({ showAccessNotice = true, message, usageSummary, s
|
|
|
209
216
|
runStreaming: runStreaming && !isTerminalRunStatus({ status: message.runStatus }),
|
|
210
217
|
});
|
|
211
218
|
const footerUsage = showUsageLine ? usage.footer : undefined;
|
|
212
|
-
const lastFinishedReply =
|
|
219
|
+
const lastFinishedReply = lastFinishedReplyIndex({ runInProgress, usage });
|
|
213
220
|
const retriedRows = retriedToolRowIds({ rows: timeline.rows });
|
|
214
221
|
const renderToolCard = (row) => (_jsx(ToolCard, { retried: retriedRows.has(row.id), use: row.use, result: row.result, runStreaming: runStreaming, runSucceeded: runSucceeded, ...(projectFileNames !== undefined ? { projectFileNames } : {}), ...(onRequestOpenFile !== undefined ? { onRequestOpenFile } : {}) }, row.id));
|
|
215
222
|
// Hidden mode reuses the same disclosure and card renderer for unrecovered failures.
|
|
@@ -34,7 +34,7 @@ function NativeToolPolicyWarning({ agent, policy, isApi }) {
|
|
|
34
34
|
if (!agent || isApi)
|
|
35
35
|
return null;
|
|
36
36
|
const warning = policy ? assessNativeToolPolicy({ def: agent, policy }) : null;
|
|
37
|
-
return _jsxs(_Fragment, { children: [agent.fullCliAccessNote ? _jsx(FullAccessNoteTip, { note: agent.fullCliAccessNote, t: t }) : null, warning ? _jsx("
|
|
37
|
+
return _jsxs(_Fragment, { children: [agent.fullCliAccessNote ? _jsx(FullAccessNoteTip, { note: agent.fullCliAccessNote, t: t }) : null, warning ? _jsx(InfoTip, { label: t(warning), triggerLabel: t('Agent permissions required'), placement: "top", icon: _jsx(RemixIcon, { name: "alert-line", size: 16 }) }) : null] });
|
|
38
38
|
}
|
|
39
39
|
function RuntimeModeButtons({ executionMode, daemonOnline, apiModeAvailable, onExecutionModeChange, t, }) {
|
|
40
40
|
const localModeLabel = executionMode === 'local' ? t('active') : '';
|
|
@@ -363,6 +363,7 @@ export function ChatPane(props) {
|
|
|
363
363
|
uploadAttachments: p.uploadAttachments,
|
|
364
364
|
onActivityChange: p.onActivityChange,
|
|
365
365
|
onMessagesChange: p.onMessagesChange,
|
|
366
|
+
subscribeToSurfaces: p.subscribeToSurfaces,
|
|
366
367
|
redactUserText: p.redactUserText,
|
|
367
368
|
onSecretRedacted: p.onSecretRedacted,
|
|
368
369
|
workingDirectory: p.workingDirectory,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SurfaceEmission } from '@jini-ai/core';
|
|
2
|
+
export interface FrontendRequestCallerRequired {
|
|
3
|
+
readonly path: string;
|
|
4
|
+
readonly fetch: typeof globalThis.fetch;
|
|
5
|
+
readonly bindToken: () => string | undefined;
|
|
6
|
+
readonly onSurface: (emission: SurfaceEmission) => void;
|
|
7
|
+
}
|
|
8
|
+
export interface FrontendRequestInput {
|
|
9
|
+
readonly capabilityId: string;
|
|
10
|
+
readonly input: Record<string, unknown>;
|
|
11
|
+
readonly signal: AbortSignal;
|
|
12
|
+
}
|
|
13
|
+
/** Execute one browser-requested capability through the host's authenticated gate, never retrying.
|
|
14
|
+
* The current bind token and serialized arguments are captured before the first asynchronous wait.
|
|
15
|
+
* @returns A caller that renders cards immediately and resolves only after the tool settles.
|
|
16
|
+
* @throws On absent attachment, HTTP refusal, cancellation or an incomplete execution stream.
|
|
17
|
+
* @complexity O(request + response bytes) time; O(largest response frame) space, capped at 5 MiB.
|
|
18
|
+
* @overallScore 100
|
|
19
|
+
*/
|
|
20
|
+
export declare function createFrontendRequestCaller({ path, fetch, bindToken, onSurface }: FrontendRequestCallerRequired, { headers }?: {
|
|
21
|
+
headers?: () => Record<string, string>;
|
|
22
|
+
}): (required: FrontendRequestInput, optional?: Record<string, never>) => Promise<unknown>;
|
|
23
|
+
//# sourceMappingURL=create-frontend-request-caller.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readSurfaceFrames } from './surface-recovery-stream.js';
|
|
2
|
+
/** Execute one browser-requested capability through the host's authenticated gate, never retrying.
|
|
3
|
+
* The current bind token and serialized arguments are captured before the first asynchronous wait.
|
|
4
|
+
* @returns A caller that renders cards immediately and resolves only after the tool settles.
|
|
5
|
+
* @throws On absent attachment, HTTP refusal, cancellation or an incomplete execution stream.
|
|
6
|
+
* @complexity O(request + response bytes) time; O(largest response frame) space, capped at 5 MiB.
|
|
7
|
+
* @overallScore 100
|
|
8
|
+
*/
|
|
9
|
+
export function createFrontendRequestCaller({ path, fetch, bindToken, onSurface }, { headers = () => ({}) } = {}) {
|
|
10
|
+
return async ({ capabilityId, input, signal }, _optional = {}) => {
|
|
11
|
+
const token = bindToken();
|
|
12
|
+
if (!token)
|
|
13
|
+
throw new Error('Page control has not attached to this browser tab. Reopen the assistant to retry.');
|
|
14
|
+
const response = await fetch(path, { method: 'POST', credentials: 'same-origin',
|
|
15
|
+
headers: { ...headers(), 'content-type': 'application/json' },
|
|
16
|
+
body: JSON.stringify({ capabilityId, input, bindToken: token }), signal,
|
|
17
|
+
});
|
|
18
|
+
if (!response.ok) {
|
|
19
|
+
const body = await response.json();
|
|
20
|
+
throw new Error(body.error ?? `The page request was refused (HTTP ${response.status}).`);
|
|
21
|
+
}
|
|
22
|
+
let completed = false;
|
|
23
|
+
let output;
|
|
24
|
+
await readSurfaceFrames({ response, onFrame: frame => {
|
|
25
|
+
switch (frame.type) {
|
|
26
|
+
case 'surface':
|
|
27
|
+
if (frame.emission)
|
|
28
|
+
onSurface(frame.emission);
|
|
29
|
+
return;
|
|
30
|
+
case 'throw':
|
|
31
|
+
throw new Error(frame.error?.message ?? 'The page action could not be executed.');
|
|
32
|
+
case 'result':
|
|
33
|
+
if (completed)
|
|
34
|
+
throw new Error('The page action returned more than one result.');
|
|
35
|
+
output = completedOutput(frame.result);
|
|
36
|
+
completed = true;
|
|
37
|
+
return;
|
|
38
|
+
default: return;
|
|
39
|
+
}
|
|
40
|
+
} });
|
|
41
|
+
if (!completed)
|
|
42
|
+
throw new Error('The page action ended without a result. Its outcome is unknown; it was not retried.');
|
|
43
|
+
return output;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Only a completed gated execution has an output; refusals keep the host's specific message.
|
|
47
|
+
* @complexity O(1) time and space.
|
|
48
|
+
* @overallScore 100
|
|
49
|
+
*/
|
|
50
|
+
function completedOutput(value) {
|
|
51
|
+
const result = value;
|
|
52
|
+
if (result?.status !== 'completed')
|
|
53
|
+
throw new Error(result?.error ?? `The page action was ${result?.status ?? 'not completed'}.`);
|
|
54
|
+
return result.output;
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=create-frontend-request-caller.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type AcceptedChatInput } from '../../../../core/accepted-input.js';
|
|
2
|
+
import type { SurfaceEmission } from '@jini-ai/core';
|
|
2
3
|
import type { UserTextRedactionOptions } from '../../../../core/user-text-redaction.js';
|
|
3
4
|
import { type ChatPaneSendBlocker } from '../rules.js';
|
|
4
5
|
import type { ChatPaneActivity, ChatPaneAgent, ChatPaneAttachmentUploadOptions, ChatPaneAgentSelection, ChatPaneRunContext, ChatPaneWorkingDirectoryAccess, DeliverMidRunMessage, DeliverTypedAnswer, TypedAnswerNotice } from '../types.js';
|
|
@@ -28,6 +29,7 @@ export interface UseChatPaneOptions extends UserTextRedactionOptions {
|
|
|
28
29
|
uploadAttachments?: (files: File[], options?: ChatPaneAttachmentUploadOptions) => Promise<ChatAttachment[]>;
|
|
29
30
|
onActivityChange?: (activity: ChatPaneActivity) => void;
|
|
30
31
|
onMessagesChange?: (messages: ChatMessage[]) => void;
|
|
32
|
+
subscribeToSurfaces?: (listener: (emission: SurfaceEmission) => void) => () => void;
|
|
31
33
|
workingDirectory?: string | null;
|
|
32
34
|
initialWorkingDirectory?: string | null;
|
|
33
35
|
onChangeWorkingDirectory?: (workingDirectory: string | null) => void;
|
|
@@ -246,6 +246,14 @@ export function useChatPane(options) {
|
|
|
246
246
|
// absent), so only the falsy "no agent selected" case should omit the key.
|
|
247
247
|
agentId: selection.agentId || undefined,
|
|
248
248
|
} }));
|
|
249
|
+
useEffect(() => options.subscribeToSurfaces?.(emission => {
|
|
250
|
+
// Browser-requested cards are not agent turns and cannot supply run/answer authority.
|
|
251
|
+
const data = emission.channel === 'mcp-ui' ? emission.payload.resource : emission.payload;
|
|
252
|
+
conversation.setMessages(messages => [...messages, {
|
|
253
|
+
id: createChatLocalId({}), role: 'assistant', content: '', createdAt: Date.now(),
|
|
254
|
+
events: [{ kind: 'ext', name: emission.channel, data }],
|
|
255
|
+
}]);
|
|
256
|
+
}), [options.subscribeToSurfaces, conversation.setMessages]);
|
|
249
257
|
const composer = useComposer(definedProps({ source: {
|
|
250
258
|
initialDraft: options.initialDraft,
|
|
251
259
|
historyMessages: conversation.messages,
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import type { SurfaceEmission } from '@jini-ai/core';
|
|
2
|
-
/** Read bounded NDJSON frames as they arrive
|
|
3
|
-
*
|
|
4
|
-
* @
|
|
2
|
+
/** Read bounded NDJSON frames as they arrive; waiting for response.text() would hide a held card.
|
|
3
|
+
* @complexity O(response bytes) time; O(largest frame) space, capped at 5 MiB per frame.
|
|
4
|
+
* @overallScore 100
|
|
5
|
+
*/
|
|
6
|
+
export declare function readSurfaceFrames({ response, onFrame }: {
|
|
7
|
+
response: Response;
|
|
8
|
+
onFrame: (frame: Record<string, unknown>) => void;
|
|
9
|
+
}, _options?: {}): Promise<void>;
|
|
10
|
+
/** Restore cards through the same bounded stream reader used by runless frontend requests.
|
|
11
|
+
* @complexity O(response bytes) time; O(largest frame) space.
|
|
12
|
+
* @overallScore 100
|
|
5
13
|
*/
|
|
6
14
|
export declare function readSurfaceRecoveryStream({ response, onSurface }: {
|
|
7
15
|
response: Response;
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
/** Read bounded NDJSON frames as they arrive
|
|
2
|
-
*
|
|
3
|
-
* @
|
|
1
|
+
/** Read bounded NDJSON frames as they arrive; waiting for response.text() would hide a held card.
|
|
2
|
+
* @complexity O(response bytes) time; O(largest frame) space, capped at 5 MiB per frame.
|
|
3
|
+
* @overallScore 100
|
|
4
4
|
*/
|
|
5
|
-
export async function
|
|
5
|
+
export async function readSurfaceFrames({ response, onFrame }, _options = {}) {
|
|
6
6
|
const reader = response.body?.getReader();
|
|
7
7
|
if (!reader)
|
|
8
|
-
throw new Error('The
|
|
8
|
+
throw new Error('The interactive page response stream is unavailable.');
|
|
9
9
|
const decoder = new TextDecoder();
|
|
10
10
|
let buffer = '';
|
|
11
|
-
let restored = false;
|
|
12
11
|
const accept = (text) => {
|
|
13
12
|
if (!text.trim())
|
|
14
13
|
return;
|
|
14
|
+
if (text.length > 5 * 1024 * 1024)
|
|
15
|
+
throw new Error('The interactive card is too large.');
|
|
15
16
|
const frame = JSON.parse(text);
|
|
16
|
-
if (frame
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
restored = frame.ok === true;
|
|
17
|
+
if (!frame || typeof frame !== 'object' || Array.isArray(frame))
|
|
18
|
+
throw new Error('The interactive response contains an invalid frame.');
|
|
19
|
+
onFrame(frame);
|
|
20
20
|
};
|
|
21
21
|
try {
|
|
22
22
|
for (;;) {
|
|
@@ -24,24 +24,34 @@ export async function readSurfaceRecoveryStream({ response, onSurface }, _option
|
|
|
24
24
|
buffer += decoder.decode(chunk.value, { stream: !chunk.done });
|
|
25
25
|
const lines = buffer.split('\n');
|
|
26
26
|
buffer = lines.pop();
|
|
27
|
-
for (const line of lines)
|
|
28
|
-
if (line.length > 5 * 1024 * 1024)
|
|
29
|
-
throw new Error('The restored card is too large.');
|
|
27
|
+
for (const line of lines)
|
|
30
28
|
accept(line);
|
|
31
|
-
}
|
|
32
29
|
if (buffer.length > 5 * 1024 * 1024)
|
|
33
|
-
throw new Error('The
|
|
30
|
+
throw new Error('The interactive card is too large.');
|
|
34
31
|
if (chunk.done)
|
|
35
32
|
break;
|
|
36
33
|
}
|
|
37
34
|
accept(buffer);
|
|
38
|
-
if (!restored)
|
|
39
|
-
throw new Error('The card could not be restored.');
|
|
40
|
-
return { delivered: true };
|
|
41
35
|
}
|
|
42
36
|
finally {
|
|
43
37
|
await reader.cancel();
|
|
44
38
|
reader.releaseLock();
|
|
45
39
|
}
|
|
46
40
|
}
|
|
41
|
+
/** Restore cards through the same bounded stream reader used by runless frontend requests.
|
|
42
|
+
* @complexity O(response bytes) time; O(largest frame) space.
|
|
43
|
+
* @overallScore 100
|
|
44
|
+
*/
|
|
45
|
+
export async function readSurfaceRecoveryStream({ response, onSurface }, _options = {}) {
|
|
46
|
+
let restored = false;
|
|
47
|
+
await readSurfaceFrames({ response, onFrame: frame => {
|
|
48
|
+
if (frame.type === 'surface' && frame.emission)
|
|
49
|
+
onSurface?.(frame.emission);
|
|
50
|
+
if (frame.type === 'restored')
|
|
51
|
+
restored = frame.ok === true;
|
|
52
|
+
} });
|
|
53
|
+
if (!restored)
|
|
54
|
+
throw new Error('The card could not be restored.');
|
|
55
|
+
return { delivered: true };
|
|
56
|
+
}
|
|
47
57
|
//# sourceMappingURL=surface-recovery-stream.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SurfaceEmission } from '@jini-ai/core';
|
|
1
2
|
import type { UserTextRedactionOptions } from '../../../core/user-text-redaction.js';
|
|
2
3
|
import type { NativeToolPolicyInput } from '@jini-ai/agent-runtime/native-tool-policy';
|
|
3
4
|
import type { CSSProperties, ReactNode, RefObject } from 'react';
|
|
@@ -226,6 +227,8 @@ export interface ChatPaneProps extends UserTextRedactionOptions {
|
|
|
226
227
|
onActivityChange?: (activity: ChatPaneActivity) => void;
|
|
227
228
|
/** Fires with the full message list whenever it changes — the read side of an otherwise write-only pane, for a host that needs to inspect or test what the conversation actually contains. */
|
|
228
229
|
onMessagesChange?: (messages: ChatMessage[]) => void;
|
|
230
|
+
/** Host-only request surfaces enter the current transcript through its existing sandbox renderer. */
|
|
231
|
+
subscribeToSurfaces?: (listener: (emission: SurfaceEmission) => void) => () => void;
|
|
229
232
|
onRescanAgents?: () => void;
|
|
230
233
|
scanningAgents?: boolean;
|
|
231
234
|
daemonOnline?: boolean;
|
package/dist/react/index.d.ts
CHANGED
|
@@ -154,8 +154,8 @@ export type { JiniChatProviderProps, JiniChatSlots } from './components/JiniChat
|
|
|
154
154
|
* `createDomPageDriver`/`currentAgentPage` moved to `@jini-ai/agentic/dom` in the 2026-07-26
|
|
155
155
|
* extraction — re-exported here so existing `@jini-ai/chat-react` importers keep working.
|
|
156
156
|
*/
|
|
157
|
-
export { createFrontendSessionBridge } from './agent-bridge/frontend-session-bridge.js';
|
|
158
|
-
export type { FrontendSessionBridge, FrontendSessionBridgeArgs, FrontendSessionBridgeOptions, } from './agent-bridge/frontend-session-bridge.js';
|
|
157
|
+
export { createFrontendSessionBridge, createFrontendNavigationGuard } from './agent-bridge/frontend-session-bridge.js';
|
|
158
|
+
export type { FrontendSessionBridge, FrontendNavigationGuard, FrontendSessionBridgeArgs, FrontendSessionBridgeOptions, } from './agent-bridge/frontend-session-bridge.js';
|
|
159
159
|
export { createDomPageDriver, currentAgentPage, type DomPageDriverOptions } from '@jini-ai/agentic/dom';
|
|
160
160
|
export { createBrowserComposerHistoryStorage } from './hooks/composer-history-storage.js';
|
|
161
161
|
export type { ComposerHistoryStoragePort } from '../core/composer-history.js';
|
|
@@ -175,4 +175,6 @@ export type { AgentPluginUsage, AgentPluginUsage as Usage } from '../core/agent-
|
|
|
175
175
|
export { acceptedInputEvent, acceptedInputUserEvent, projectAcceptedInputEvents } from '../core/accepted-input.js';
|
|
176
176
|
export type { AcceptedChatInput } from '../core/accepted-input.js';
|
|
177
177
|
export { isAwaitingAnswer } from '../core/run-activity.js';
|
|
178
|
+
export { createFrontendRequestCaller } from './features/chat-pane/create-frontend-request-caller.js';
|
|
179
|
+
export type { FrontendRequestCallerRequired, FrontendRequestInput } from './features/chat-pane/create-frontend-request-caller.js';
|
|
178
180
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/react/index.js
CHANGED
|
@@ -133,7 +133,7 @@ export { JiniChatProvider, useJiniChatSlots, useOnFeedback } from './components/
|
|
|
133
133
|
* `createDomPageDriver`/`currentAgentPage` moved to `@jini-ai/agentic/dom` in the 2026-07-26
|
|
134
134
|
* extraction — re-exported here so existing `@jini-ai/chat-react` importers keep working.
|
|
135
135
|
*/
|
|
136
|
-
export { createFrontendSessionBridge } from './agent-bridge/frontend-session-bridge.js';
|
|
136
|
+
export { createFrontendSessionBridge, createFrontendNavigationGuard } from './agent-bridge/frontend-session-bridge.js';
|
|
137
137
|
export { createDomPageDriver, currentAgentPage } from '@jini-ai/agentic/dom';
|
|
138
138
|
export { createBrowserComposerHistoryStorage } from './hooks/composer-history-storage.js';
|
|
139
139
|
// ChatPane consumers share the same redaction and draft/preview state owners.
|
|
@@ -147,4 +147,5 @@ export { isTerminalRunStatus } from '../core/messages.js';
|
|
|
147
147
|
export { AGENT_PLUGIN_STATE_CHANGED, readAgentPluginChatState, writeAgentPluginChatState } from '../core/agent-plugin-chat-state.js';
|
|
148
148
|
export { acceptedInputEvent, acceptedInputUserEvent, projectAcceptedInputEvents } from '../core/accepted-input.js';
|
|
149
149
|
export { isAwaitingAnswer } from '../core/run-activity.js';
|
|
150
|
+
export { createFrontendRequestCaller } from './features/chat-pane/create-frontend-request-caller.js';
|
|
150
151
|
//# sourceMappingURL=index.js.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { recoveredRunEvents } from "../../core/index.js";
|
|
2
|
-
import { classifyAgentServiceFailure } from "@jini-ai/agent-runtime";
|
|
2
|
+
import { classifyAgentServiceFailure, startupFailureMessage } from "@jini-ai/agent-runtime";
|
|
3
3
|
import { continuationRequest } from "../../core/durable-runs/continuation.js";
|
|
4
4
|
import { isAwaitingAnswer } from "../../core/run-activity.js";
|
|
5
5
|
import { RECOVERY_LIMIT, RUN_STALENESS_MS, SAVED_WORK_NOTICE, AWAITING_ANSWER_NOTICE, EXHAUSTED_NOTICE } from "../../core/durable-runs/policies.js";
|
|
@@ -73,6 +73,11 @@ function attemptFailure({ diagnostics }, _optional = {}) {
|
|
|
73
73
|
}
|
|
74
74
|
/** Keep the notice bounded and scoped to the same executor diagnostics as failure classification. */
|
|
75
75
|
function lastDiagnosticLine({ diagnostics }, _optional = {}) {
|
|
76
|
+
// Startup evidence is already bounded, but redact again for older saved events. Keep the
|
|
77
|
+
// complete stage/cause block; a last-line/300-character tail erased its useful context.
|
|
78
|
+
const startup = diagnostics.lastIndexOf('Startup stage:');
|
|
79
|
+
if (startup >= 0)
|
|
80
|
+
return startupFailureMessage({ stage: 'saved startup failure', error: diagnostics.slice(startup) }, {});
|
|
76
81
|
return diagnostics.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1)?.slice(0, 300);
|
|
77
82
|
}
|
|
78
83
|
function attemptFailureDetail({ run }, _optional = {}) {
|
|
@@ -130,6 +135,23 @@ function continuesNow({ run, trigger, now }, _optional = {}) {
|
|
|
130
135
|
/** Recovery coordinator behind explicit host ports. One CAS owns both the attempt and its conversation slot.
|
|
131
136
|
* Each recovery decision logs one `logger.info` line (default console) of run metadata. */
|
|
132
137
|
export function createDurableRecovery(ports, { logger = console } = {}) {
|
|
138
|
+
/** Replay starts from attemptBase. Carry this attempt's saved launch notice into it too, so
|
|
139
|
+
* reconnect/reload cannot replace that evidence with the daemon's shorter replay prefix.
|
|
140
|
+
* O(e) in saved events; no additional store read, write, or polling. */
|
|
141
|
+
function attach(run) {
|
|
142
|
+
const events = run.message.events ?? [];
|
|
143
|
+
const noticeIndex = lastIndexWhere({ events, match: event => event.kind === 'status' && event.code === 'run_launch_unconfirmed' });
|
|
144
|
+
const marker = lastIndexWhere({ events, match: event => event.kind === 'status' && event.code === 'run_recovering' });
|
|
145
|
+
if (noticeIndex <= marker) {
|
|
146
|
+
ports.attach(run, {});
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const notice = events[noticeIndex];
|
|
150
|
+
const base = [...run.attemptBase];
|
|
151
|
+
if (!base.includes(notice))
|
|
152
|
+
base.push(notice);
|
|
153
|
+
ports.attach({ ...run, attemptBase: base }, {});
|
|
154
|
+
}
|
|
133
155
|
async function finalize(run, notice, { canceled = false, detail } = {}) {
|
|
134
156
|
await ports.cancelAttempt(run, {}).catch(() => undefined);
|
|
135
157
|
// Persist the notice as an event: hook errors are live-only and would leave a reloaded
|
|
@@ -145,15 +167,30 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
|
|
|
145
167
|
return false;
|
|
146
168
|
return ports.verifyChildDead(run.child, {}).catch(() => false);
|
|
147
169
|
}
|
|
170
|
+
/** Persist rejection evidence but leave liveness and retry decisions to the existing probe. */
|
|
148
171
|
async function launchNext({ run, next, native }, _optional = {}) {
|
|
172
|
+
let stage = 'recovery launch';
|
|
149
173
|
try {
|
|
150
174
|
await ports.launch({ run: next, request: continuationRequest({ run: { ...run, message: next.message, transcript: next.transcript }, native }, {}) }, {});
|
|
175
|
+
stage = 'recovery clock';
|
|
151
176
|
await ports.store.recoveryClock({ runId: next.runId, now: ports.now(), active: false }, {});
|
|
152
177
|
}
|
|
153
|
-
catch {
|
|
178
|
+
catch (error) {
|
|
154
179
|
// A rejected/lost start response is uncertain: the daemon may already be executing. The
|
|
155
180
|
// same idempotent attempt is probed by the attached watcher before another CAS is possible.
|
|
181
|
+
const diagnostic = { kind: 'status', code: 'run_launch_unconfirmed',
|
|
182
|
+
label: 'The assistant start response was not confirmed. Checking whether this attempt is running.',
|
|
183
|
+
detail: startupFailureMessage({ stage, error }, {
|
|
184
|
+
notice: 'The assistant start response was not confirmed. Wait while this attempt is checked.',
|
|
185
|
+
}) };
|
|
186
|
+
const events = [...(next.message.events ?? []), diagnostic];
|
|
187
|
+
// Preserve the saved answer and all prior events in the existing projection write. The
|
|
188
|
+
// checkpoint's generation fence rejects a concurrent stop or successor attempt.
|
|
189
|
+
await ports.checkpoint?.({ conversationId: next.conversationId, messageId: next.messageId,
|
|
190
|
+
runId: next.runId, content: next.message.content, events }, {}).catch(() => false);
|
|
191
|
+
return { ...next, message: { ...next.message, events } };
|
|
156
192
|
}
|
|
193
|
+
return next;
|
|
157
194
|
}
|
|
158
195
|
async function continueDead(run) {
|
|
159
196
|
await ports.cancelAttempt(run, {}).catch(() => undefined);
|
|
@@ -172,8 +209,8 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
|
|
|
172
209
|
return "superseded";
|
|
173
210
|
if (next.cancelReason)
|
|
174
211
|
return finalize(next, STOPPED_NOTICE, { canceled: true });
|
|
175
|
-
await launchNext({ run, next, native });
|
|
176
|
-
|
|
212
|
+
const launched = await launchNext({ run, next, native });
|
|
213
|
+
attach(launched);
|
|
177
214
|
return "continued";
|
|
178
215
|
}
|
|
179
216
|
async function probeAttempt({ run, trigger, liveRunId }, _optional = {}) {
|
|
@@ -185,7 +222,7 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
|
|
|
185
222
|
}
|
|
186
223
|
async function reattach(run) {
|
|
187
224
|
await ports.store.recoveryClock({ runId: run.runId, now: ports.now(), active: false }, {});
|
|
188
|
-
|
|
225
|
+
attach(run);
|
|
189
226
|
return "reattached";
|
|
190
227
|
}
|
|
191
228
|
async function finalizeFailure({ run, failure, diagnostics }, _optional = {}) {
|
|
@@ -259,7 +296,7 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
|
|
|
259
296
|
// Give that idempotent launch time to appear before a 404 can advance its generation.
|
|
260
297
|
if (continuesNow({ run, trigger, now: ports.now() }))
|
|
261
298
|
return continueDead(run);
|
|
262
|
-
|
|
299
|
+
attach(run);
|
|
263
300
|
return "waiting";
|
|
264
301
|
}
|
|
265
302
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/chat",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.23",
|
|
4
4
|
"description": "Jini's chat/artifact frontend, consolidated into one package with two subpaths: framework-free chat vocabulary and pure parsers in `/core`, headless hooks + presentational components + slots in `/react`. Import only the layer you need.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -214,8 +214,8 @@
|
|
|
214
214
|
},
|
|
215
215
|
"dependencies": {
|
|
216
216
|
"kysely": "^0.29.6",
|
|
217
|
-
"@jini-ai/core": "^0.6.
|
|
218
|
-
"@jini-ai/agentic": "^0.6.
|
|
217
|
+
"@jini-ai/core": "^0.6.23",
|
|
218
|
+
"@jini-ai/agentic": "^0.6.23",
|
|
219
219
|
"@jini-ai/diagnostics": "^0.6.22",
|
|
220
220
|
"@jini-ai/db": "^0.6.19"
|
|
221
221
|
},
|
|
@@ -261,12 +261,12 @@
|
|
|
261
261
|
"pg": "8.23.0",
|
|
262
262
|
"@ag-ui/core": "0.0.58",
|
|
263
263
|
"fast-check": "4.3.0",
|
|
264
|
-
"@jini-ai/
|
|
265
|
-
"@jini-ai/
|
|
264
|
+
"@jini-ai/agentic": "^0.6.23",
|
|
265
|
+
"@jini-ai/agent-runtime": "0.6.23",
|
|
266
|
+
"@jini-ai/ui": "^0.6.23",
|
|
267
|
+
"@jini-ai/core": "^0.6.23",
|
|
266
268
|
"@jini-ai/protocol": "^0.6.19",
|
|
267
|
-
"@jini-ai/core": "^0.6.22",
|
|
268
269
|
"@jini-ai/diagnostics": "^0.6.22",
|
|
269
|
-
"@jini-ai/ui": "^0.6.22",
|
|
270
270
|
"@jini-ai/db": "0.6.19"
|
|
271
271
|
},
|
|
272
272
|
"typesVersions": {
|