@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/core/accepted-input.d.ts +10 -2
  3. package/dist/core/accepted-input.js +87 -33
  4. package/dist/core/assistant-client/durable-run-subscription.js +6 -0
  5. package/dist/core/assistant-client/entry.d.ts +1 -1
  6. package/dist/core/assistant-client/entry.js +1 -1
  7. package/dist/core/assistant-client/messages.d.ts +21 -0
  8. package/dist/core/assistant-client/messages.js +31 -0
  9. package/dist/core/checkpoint-interval.d.ts +25 -0
  10. package/dist/core/checkpoint-interval.js +38 -0
  11. package/dist/core/durable-runs/ledger-contracts.d.ts +27 -1
  12. package/dist/core/durable-runs/ports.d.ts +25 -1
  13. package/dist/core/events.d.ts +6 -0
  14. package/dist/core/index.d.ts +1 -0
  15. package/dist/core/index.js +1 -0
  16. package/dist/core/run-events/entry.js +15 -2
  17. package/dist/core/surface-recovery.d.ts +19 -0
  18. package/dist/core/surface-recovery.js +26 -0
  19. package/dist/core/tool-events.d.ts +11 -3
  20. package/dist/core/tool-events.js +75 -43
  21. package/dist/core/turn-usage.d.ts +1 -1
  22. package/dist/core/turn-usage.js +5 -3
  23. package/dist/embed-widget/browser/frontend-session-bridge.d.ts +14 -1
  24. package/dist/embed-widget/browser/frontend-session-bridge.js +120 -47
  25. package/dist/react/components/Composer.d.ts +3 -3
  26. package/dist/react/components/Composer.js +5 -4
  27. package/dist/react/components/ConversationList.js +2 -1
  28. package/dist/react/components/ConversationTitleEditor.js +2 -1
  29. package/dist/react/components/ExtEventErrorBoundary.js +2 -1
  30. package/dist/react/components/McpUiSurfaceCard.js +12 -5
  31. package/dist/react/components/MessageList.js +2 -1
  32. package/dist/react/components/MidRunUserMessage.js +1 -1
  33. package/dist/react/components/ToolCard.js +3 -2
  34. package/dist/react/components/ToolCard.rules.js +2 -1
  35. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +5 -3
  36. package/dist/react/features/chat-pane/components/ChatPane.js +1 -1
  37. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.d.ts +15 -2
  38. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +19 -3
  39. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +5 -4
  40. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +17 -9
  41. package/dist/react/features/chat-pane/hooks/useChatPaneFileDrop.hooks.js +3 -2
  42. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +20 -3
  43. package/dist/react/features/chat-pane/model-options.d.ts +10 -0
  44. package/dist/react/features/chat-pane/model-options.js +22 -3
  45. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  46. package/dist/react/features/chat-pane/styles.js +3 -1
  47. package/dist/react/features/chat-pane/surface-recovery-stream.d.ts +12 -0
  48. package/dist/react/features/chat-pane/surface-recovery-stream.js +47 -0
  49. package/dist/react/hooks/composer-draft-cache.d.ts +6 -6
  50. package/dist/react/hooks/composer-draft-cache.js +18 -17
  51. package/dist/react/hooks/useComposer.js +52 -4
  52. package/dist/react/hooks/useConversation.js +41 -22
  53. package/dist/react/hooks/useRunStream.js +2 -1
  54. package/dist/react/hooks/useSurfaceRecovery.d.ts +23 -0
  55. package/dist/react/hooks/useSurfaceRecovery.js +84 -0
  56. package/dist/react/styles/reference.css +4 -5
  57. package/dist/server/durable-runs/recover.js +3 -3
  58. package/dist/store/ports.d.ts +17 -3
  59. package/dist/store/runtime/chat-expiry-sweep.js +3 -1
  60. package/dist/store/runtime/chat-history-store.js +6 -0
  61. package/dist/store/runtime/checkpoint-delta.d.ts +35 -0
  62. package/dist/store/runtime/checkpoint-delta.js +75 -0
  63. package/dist/store/runtime/durable-run-store.js +92 -25
  64. package/dist/store/runtime/events-json.d.ts +17 -0
  65. package/dist/store/runtime/events-json.js +19 -0
  66. package/dist/store/runtime/run-guarded-chat-store.js +8 -3
  67. package/dist/store/runtime/run-ledger.d.ts +2 -2
  68. package/dist/store/runtime/run-ledger.js +120 -45
  69. package/dist/store/sql/read-version.d.ts +15 -0
  70. package/dist/store/sql/read-version.js +21 -0
  71. package/dist/store/sql/store.d.ts +9 -0
  72. package/dist/store/sql/store.js +89 -4
  73. package/dist/store/sqlite/schema.d.ts +2 -2
  74. package/dist/store/sqlite/schema.js +4 -0
  75. package/package.json +12 -12
@@ -216,31 +216,33 @@ function storedTimestamp(store, { key }) {
216
216
  function writeStoredEnvelope(prefix, { conversationId, envelope }) {
217
217
  const store = storage();
218
218
  if (!store)
219
- return;
219
+ return false;
220
220
  const key = `${prefix}${conversationId}`;
221
221
  try {
222
222
  if (envelope === null) {
223
223
  store.removeItem(key);
224
- return;
224
+ return true;
225
225
  }
226
226
  if (store.getItem(key) === null)
227
227
  pruneStored(prefix, MAX_CACHED_CONVERSATION_DRAFTS - 1);
228
228
  store.setItem(key, JSON.stringify(envelope));
229
+ return true;
229
230
  }
230
231
  catch {
231
232
  /* blocked, full, or unusable storage — memory-only is the documented degraded mode */
232
233
  /* (on a removal: blocked storage; the in-memory tier is already correct) */
234
+ return false;
233
235
  }
234
236
  }
235
237
  /**
236
- * Writes or removes the stored copy for `conversationId`.
238
+ * Writes or removes the stored copy for `conversationId`; returns false on storage failure.
237
239
  *
238
240
  * @param draft A blank draft removes the entry; see {@link writeStoredEnvelope}.
239
241
  * @complexity Time O(n) in the draft's length; see {@link writeStoredEnvelope}.
240
242
  */
241
243
  function writeStoredDraft(conversationId, draft) {
242
244
  const envelope = draft === '' ? null : { v: 1, t: Date.now(), d: draft };
243
- writeStoredEnvelope(COMPOSER_DRAFT_STORAGE_PREFIX, { conversationId, envelope });
245
+ return writeStoredEnvelope(COMPOSER_DRAFT_STORAGE_PREFIX, { conversationId, envelope });
244
246
  }
245
247
  /**
246
248
  * Records `draft` in the in-memory tier, evicting the oldest-inserted conversation first if this
@@ -274,7 +276,7 @@ export function readCachedDraft({ conversationId }) {
274
276
  return stored;
275
277
  }
276
278
  /**
277
- * Stores `draft` for `conversationId` in both tiers.
279
+ * Stores `draft` for `conversationId` in both tiers. Returns whether durable storage accepted it.
278
280
  *
279
281
  * @param conversationId No-ops when absent (`null`/`undefined`) — there is nothing to key on yet.
280
282
  * `useComposer` covers that window by adopting the typed text once an id arrives; see its own doc.
@@ -283,11 +285,11 @@ export function readCachedDraft({ conversationId }) {
283
285
  * writes `''` here.
284
286
  * @complexity Time/space: O(n) in the draft's length; see {@link writeStoredDraft}.
285
287
  */
286
- export function writeCachedDraft({ conversationId, draft }) {
288
+ export function writeCachedDraft({ conversationId, draft }, _options = {}) {
287
289
  if (!conversationId)
288
- return;
290
+ return false;
289
291
  rememberCachedDraft({ conversationId: conversationId, draft: draft });
290
- writeStoredDraft(conversationId, draft.trim() === '' ? '' : draft);
292
+ return writeStoredDraft(conversationId, draft.trim() === '' ? '' : draft);
291
293
  }
292
294
  /**
293
295
  * Stores `draft` for `conversationId` in the in-memory tier only, leaving `localStorage` alone.
@@ -436,26 +438,25 @@ export function writeCachedAttachmentBatchId({ conversationId, batchId }, _optio
436
438
  setBounded(stagedBatchIds, { key: conversationId, value: batchId });
437
439
  }
438
440
  /**
439
- * Stores `attachments` for `conversationId` in both tiers.
441
+ * Stores `attachments` for `conversationId` in both tiers. Returns whether storage accepted it.
440
442
  *
441
443
  * @param attachments An empty list deletes the entry, so a sent or cleared turn leaves nothing to
442
444
  * restore — the same rule the draft text follows.
443
445
  * @complexity Time/space: O(n) in the number of references.
444
446
  */
445
- export function writeCachedAttachments({ conversationId, attachments }) {
447
+ export function writeCachedAttachments({ conversationId, attachments }, _options = {}) {
446
448
  if (!conversationId)
447
- return;
449
+ return false;
448
450
  if (attachments.length === 0) {
449
451
  stagedAttachments.delete(conversationId);
450
452
  // A sent or cleared turn's batch is spent; the next upload starts a fresh one.
451
453
  stagedBatchIds.delete(conversationId);
452
- writeStoredEnvelope(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, { conversationId, envelope: null });
453
- return;
454
+ return writeStoredEnvelope(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, { conversationId, envelope: null });
454
455
  }
455
456
  setBounded(stagedAttachments, { key: conversationId, value: attachments });
456
457
  const batchId = stagedBatchIds.get(conversationId);
457
458
  const envelope = { v: 1, t: Date.now(), a: attachments, ...(batchId === undefined ? {} : { b: batchId }) };
458
- writeStoredEnvelope(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, { conversationId, envelope });
459
+ return writeStoredEnvelope(COMPOSER_ATTACHMENTS_STORAGE_PREFIX, { conversationId, envelope });
459
460
  }
460
461
  /**
461
462
  * Test-only: empties the cache so test cases cannot leak drafts into each other.
@@ -489,11 +490,11 @@ export function readCachedFolders({ conversationId }, _options = {}) {
489
490
  } });
490
491
  return stored ?? [];
491
492
  }
492
- /** Persist exact strings independently of text and attachment validation. */
493
+ /** Persist exact strings independently of text and attachment validation; report storage failure. */
493
494
  export function writeCachedFolders({ conversationId, folders }, _options = {}) {
494
495
  if (!conversationId)
495
- return;
496
+ return false;
496
497
  setBounded(folderDrafts, { key: conversationId, value: [...folders] });
497
- writeStoredEnvelope(COMPOSER_FOLDERS_STORAGE_PREFIX, { conversationId, envelope: folders.length ? { v: 1, t: Date.now(), folders } : null });
498
+ return writeStoredEnvelope(COMPOSER_FOLDERS_STORAGE_PREFIX, { conversationId, envelope: folders.length ? { v: 1, t: Date.now(), folders } : null });
498
499
  }
499
500
  //# sourceMappingURL=composer-draft-cache.js.map
@@ -49,16 +49,19 @@ const EMPTY_MENTION = { open: false, query: '', results: [] };
49
49
  */
50
50
  export const DRAFT_PERSIST_DEBOUNCE_MS = 800;
51
51
  /**
52
- * One pending durable draft write at a time, trailing-edge debounced. Flushes on unmount and when
52
+ * One pending durable draft write at a time, trailing-edge debounced. Release checks flush and
53
+ * consult the draft owner without adding a browser reload prompt. Flushes on unmount and when
53
54
  * the page is hidden (`pagehide`, or `visibilitychange` to hidden — the last event a mobile browser
54
55
  * reliably delivers before discarding the tab).
55
56
  * @param pageLifecycle The page-exit event sources, captured at mount; `undefined` resolves the
56
57
  * browser's own, `null` subscribes to nothing (unmount still flushes).
57
58
  * @complexity Time/space: O(1) per call.
58
59
  */
59
- function useDeferredDraftWrite(pageLifecycle) {
60
+ function useDeferredDraftWrite(pageLifecycle, canRelease) {
60
61
  const [lifecycle] = useState(() => (pageLifecycle === undefined ? browserPageLifecycle() : pageLifecycle));
61
62
  const pendingRef = useRef(null);
63
+ const canReleaseRef = useRef(canRelease);
64
+ canReleaseRef.current = canRelease;
62
65
  const cancel = useCallback(() => {
63
66
  const pending = pendingRef.current;
64
67
  if (!pending)
@@ -91,10 +94,23 @@ function useDeferredDraftWrite(pageLifecycle) {
91
94
  if (doc.visibilityState === 'hidden')
92
95
  flush();
93
96
  };
97
+ const onReleaseCheck = (event) => {
98
+ try {
99
+ flush();
100
+ if (!canReleaseRef.current())
101
+ event.preventDefault();
102
+ }
103
+ catch {
104
+ // Host persistence may throw; a release must never discard the only surviving copy.
105
+ event.preventDefault();
106
+ }
107
+ };
94
108
  page.addEventListener('pagehide', flush);
109
+ page.addEventListener('jini:release-check', onReleaseCheck);
95
110
  doc.addEventListener('visibilitychange', onVisibilityChange);
96
111
  return () => {
97
112
  page.removeEventListener('pagehide', flush);
113
+ page.removeEventListener('jini:release-check', onReleaseCheck);
98
114
  doc.removeEventListener('visibilitychange', onVisibilityChange);
99
115
  flush();
100
116
  };
@@ -124,7 +140,20 @@ export function useComposer(options = {}) {
124
140
  const [attachments, setAttachments] = useState([]);
125
141
  const [agent, setAgent] = useState(options.initialAgent);
126
142
  const [mention, setMention] = useState(EMPTY_MENTION);
127
- const deferredWrite = useDeferredDraftWrite(options.pageLifecycle);
143
+ const pendingUploadsRef = useRef(0);
144
+ const deferredWrite = useDeferredDraftWrite(options.pageLifecycle, () => {
145
+ if (pendingUploadsRef.current > 0)
146
+ return false;
147
+ // Retry even after the debounce: the cache's memory tier cannot survive guest destruction.
148
+ // Empty values are durable deletions too. A failed remove must not resurrect a cleared draft
149
+ // after this guest is destroyed and storage becomes readable again.
150
+ const textSaved = conversationId ? writeCachedDraft({ conversationId, draft }) : draft.trim() === '';
151
+ const foldersSaved = conversationId ? writeCachedFolders({ conversationId, folders }) : folders.length === 0;
152
+ // Without the host's liveness validator, a remount deliberately does not restore files.
153
+ const filesSaved = canReleaseAttachments({ conversationId, attachments, validateAttachments,
154
+ hydratedFor: attachmentsHydratedForRef.current });
155
+ return textSaved && foldersSaved && filesSaved;
156
+ });
128
157
  const commitDraft = useCallback((next) => {
129
158
  setDraftState(next);
130
159
  rememberCachedDraft({ conversationId: conversationId, draft: next });
@@ -263,7 +292,14 @@ export function useComposer(options = {}) {
263
292
  if (!project?.uploadFiles) {
264
293
  throw new Error('addAttachments requires ProjectContextValue.uploadFiles to be wired by the host');
265
294
  }
266
- const uploaded = await project.uploadFiles(files);
295
+ pendingUploadsRef.current++;
296
+ let uploaded;
297
+ try {
298
+ uploaded = await project.uploadFiles(files);
299
+ }
300
+ finally {
301
+ pendingUploadsRef.current--;
302
+ }
267
303
  setAttachments((prev) => [...prev, ...uploaded]);
268
304
  // `uploadFiles` resolves 1:1 with `files`, in order, on success (see
269
305
  // `create-daemon-attachment-uploader.ts`'s "preserved order" doc) - any failure rejects the
@@ -335,4 +371,16 @@ export function useComposer(options = {}) {
335
371
  flushDraft: deferredWrite.flush,
336
372
  }), [draft, history, setDraft, folders, addFolders, removeFolder, attachments, addAttachments, addAttachment, removeAttachment, clearAttachments, agent, mention, openMention, closeMention, selectMention, canSubmit, reset, deferredWrite.flush]);
337
373
  }
374
+ /** Never erase a pending restore; once settled, confirm persistence of empty lists as well.
375
+ * Unkeyed empty composers have no stored entry to delete. O(staged files), bounded by the cache.
376
+ */
377
+ function canReleaseAttachments({ conversationId, attachments, validateAttachments, hydratedFor }, _options = {}) {
378
+ if (!conversationId)
379
+ return attachments.length === 0;
380
+ if (hydratedFor !== conversationId)
381
+ return false;
382
+ if (attachments.length > 0 && !validateAttachments)
383
+ return false;
384
+ return writeCachedAttachments({ conversationId, attachments });
385
+ }
338
386
  //# sourceMappingURL=useComposer.js.map
@@ -1,3 +1,4 @@
1
+ import { formatErrorForLog } from '@jini-ai/core';
1
2
  import { acceptedInputUserEvent, projectAcceptedInputEvents } from '../../core/accepted-input.js';
2
3
  import { redactUserHistory, redactUserMessage } from '../../core/user-text-redaction.js';
3
4
  import { preserveAssistantContent, mergeRunEvents } from '../../core/durable-projection.js';
@@ -36,6 +37,39 @@ function pendingUserMessage({ content, sendOptions, createMessageId }, options =
36
37
  ...(sendOptions.attachments ? { attachments: sendOptions.attachments } : {}),
37
38
  } }, options).message;
38
39
  }
40
+ /** A checkpoint's run id proves acceptance even if the original start request is still pending.
41
+ * @returns A copy with pending state cleared only for this run's preceding user row; no I/O.
42
+ * @complexity O(1) time/space, called in the existing transcript reconciliation pass.
43
+ */
44
+ function acceptPendingUserMessage({ message, reply, assistantId, runId }, _options = {}) {
45
+ if (!message.inputPending || reply?.id !== assistantId || runId === null)
46
+ return message;
47
+ return { ...message, inputPending: false };
48
+ }
49
+ /** The queued assistant bubble a send opens; the run's events fill it in. */
50
+ function pendingAssistantMessage({ id }, { agentId } = {}) {
51
+ return {
52
+ id,
53
+ role: 'assistant',
54
+ content: '',
55
+ ...(agentId !== undefined ? { agentId } : {}),
56
+ runStatus: 'queued',
57
+ createdAt: Date.now(),
58
+ startedAt: Date.now(),
59
+ };
60
+ }
61
+ /** What `run.start` receives for one send: only the options the caller actually set. */
62
+ function runStartInput({ assistantMessageId, history, conversationId, sendOptions }, { agentId } = {}) {
63
+ return {
64
+ assistantMessageId,
65
+ history,
66
+ ...(agentId !== undefined ? { agentId } : {}),
67
+ conversationId,
68
+ ...(sendOptions.folders !== undefined ? { folders: sendOptions.folders } : {}),
69
+ ...(sendOptions.attachments !== undefined ? { attachments: sendOptions.attachments } : {}),
70
+ ...(sendOptions.context !== undefined ? { context: sendOptions.context } : {}),
71
+ };
72
+ }
39
73
  export function useConversation(options) {
40
74
  const { transport, conversationId = null, agentId, createMessageId = defaultCreateMessageId } = options;
41
75
  const [messages, setMessagesState] = useState(() => redactUserHistory({ history: options.initialMessages ?? [] }, { ...(options.redactUserText ? { redactUserText: options.redactUserText } : {}) }));
@@ -66,7 +100,7 @@ export function useConversation(options) {
66
100
  }
67
101
  catch (error) {
68
102
  if (!disposed)
69
- console.error('[chat] conversation refresh failed', error);
103
+ console.error('[chat] conversation refresh failed', formatErrorForLog({ error }));
70
104
  }
71
105
  finally {
72
106
  pending = false;
@@ -139,9 +173,11 @@ export function useConversation(options) {
139
173
  const assistantId = activeAssistantIdRef.current;
140
174
  if (!assistantId)
141
175
  return;
142
- setMessagesState((prev) => prev.map((m) => {
176
+ setMessagesState((prev) => prev.map((m, index) => {
143
177
  if (m.id !== assistantId)
144
- return m;
178
+ return acceptPendingUserMessage({
179
+ message: m, reply: prev[index + 1], assistantId, runId: run.runId,
180
+ });
145
181
  // A plain if/else chain (not a nested ternary) — deliberately, so
146
182
  // each of the four mapped statuses is its own independently
147
183
  // trackable branch under coverage instrumentation. The fallback
@@ -184,20 +220,11 @@ export function useConversation(options) {
184
220
  // permanently unset.
185
221
  useEffect(() => {
186
222
  applyRunToAssistantMessage();
187
- // eslint-disable-next-line react-hooks/exhaustive-deps
188
223
  }, [run.events, run.status, run.runId]);
189
224
  const sendMessage = useCallback(async (content, sendOptions = {}) => {
190
225
  const userMessage = pendingUserMessage({ content, sendOptions, createMessageId }, options);
191
226
  const resolvedAgentId = sendOptions.agentId ?? agentId;
192
- const assistantMessage = {
193
- id: createMessageId(),
194
- role: 'assistant',
195
- content: '',
196
- ...(resolvedAgentId !== undefined ? { agentId: resolvedAgentId } : {}),
197
- runStatus: 'queued',
198
- createdAt: Date.now(),
199
- startedAt: Date.now(),
200
- };
227
+ const assistantMessage = pendingAssistantMessage({ id: createMessageId() }, { agentId: resolvedAgentId });
201
228
  activeAssistantIdRef.current = assistantMessage.id;
202
229
  setSendRefused(false);
203
230
  const history = redactUserHistory({ history: [...messagesRef.current, userMessage] }, options);
@@ -209,15 +236,7 @@ export function useConversation(options) {
209
236
  // (host stuck-chat investigation, 2026-09-27).
210
237
  setMessagesState((prev) => [...prev, userMessage, assistantMessage]);
211
238
  setScrollIntent(true);
212
- const accepted = await run.start({
213
- assistantMessageId: assistantMessage.id,
214
- history,
215
- ...(resolvedAgentId !== undefined ? { agentId: resolvedAgentId } : {}),
216
- conversationId,
217
- ...(sendOptions.folders !== undefined ? { folders: sendOptions.folders } : {}),
218
- ...(sendOptions.attachments !== undefined ? { attachments: sendOptions.attachments } : {}),
219
- ...(sendOptions.context !== undefined ? { context: sendOptions.context } : {}),
220
- });
239
+ const accepted = await run.start(runStartInput({ assistantMessageId: assistantMessage.id, history, conversationId, sendOptions }, { agentId: resolvedAgentId }));
221
240
  if (!accepted) {
222
241
  // The bubbles go, but the reason must not: it was rendered only on the assistant bubble.
223
242
  setMessagesState(prev => prev.filter(message => message.id !== userMessage.id && message.id !== assistantMessage.id));
@@ -1,3 +1,4 @@
1
+ import { formatErrorForLog } from '@jini-ai/core';
1
2
  /**
2
3
  * @module useRunStream
3
4
  *
@@ -20,7 +21,7 @@
20
21
  import { mergeRunEvents } from '../../core/durable-projection.js';
21
22
  import { useCallback, useEffect, useRef, useState } from 'react';
22
23
  function reportRunStreamHostEffectFailure(effectName, error) {
23
- console.error(`[@jini-ai/chat] useRunStream ${effectName} host effect failed:`, error);
24
+ console.error(`[@jini-ai/chat] useRunStream ${effectName} host effect failed:`, formatErrorForLog({ error }));
24
25
  }
25
26
  /**
26
27
  * Fire-and-forget teardown call to `transport.stopRun`. Invokes it synchronously, then observes
@@ -0,0 +1,23 @@
1
+ import { type McpUiToolCallHandler, type UIResource } from '@jini-ai/ui/mcp-ui';
2
+ import type { ExtEventCall } from '../ext-event-renderer-registry.js';
3
+ /** Restore only an unanswered call: completed decisions must never execute twice. */
4
+ export declare function surfaceNeedsRecovery({ call, runStreaming }: {
5
+ call: ExtEventCall | undefined;
6
+ runStreaming: boolean;
7
+ }, _options?: {}): boolean;
8
+ /** Nonvisual card lifecycle: mount/focus restores the server's original invocation; late clicks
9
+ * use the same stream. A component owns its subscription and aborts it on teardown.
10
+ * @complexity O(card document size) per emission; O(1) subscriptions per visible card.
11
+ */
12
+ export declare function useSurfaceRecovery({ resource, call, runStreaming, onToolCall }: {
13
+ resource: UIResource | undefined;
14
+ call: ExtEventCall | undefined;
15
+ runStreaming: boolean;
16
+ onToolCall: McpUiToolCallHandler | undefined;
17
+ }, _options?: {}): {
18
+ resource: UIResource | undefined;
19
+ restored: boolean;
20
+ live: boolean;
21
+ onToolCall: McpUiToolCallHandler | undefined;
22
+ };
23
+ //# sourceMappingURL=useSurfaceRecovery.d.ts.map
@@ -0,0 +1,84 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { parseUIResource } from '@jini-ai/ui/mcp-ui';
3
+ import { recoverableSurfaceResource, surfaceRecoveryInfo, SURFACE_EXCHANGE_META } from '../../core/surface-recovery.js';
4
+ /** Restore only an unanswered call: completed decisions must never execute twice. */
5
+ export function surfaceNeedsRecovery({ call, runStreaming }, _options = {}) {
6
+ if (!call)
7
+ return false;
8
+ if (!call.result)
9
+ return !runStreaming;
10
+ return call.result.isError || /\b(expired|abandoned|did not respond|run ended)\b/i.test(call.result.content);
11
+ }
12
+ function recoveryCaller(call) {
13
+ if (!call || !('restore' in call) || !('answer' in call))
14
+ return undefined;
15
+ if (typeof call.restore !== 'function' || typeof call.answer !== 'function')
16
+ return undefined;
17
+ return call;
18
+ }
19
+ /** Nonvisual card lifecycle: mount/focus restores the server's original invocation; late clicks
20
+ * use the same stream. A component owns its subscription and aborts it on teardown.
21
+ * @complexity O(card document size) per emission; O(1) subscriptions per visible card.
22
+ */
23
+ export function useSurfaceRecovery({ resource, call, runStreaming, onToolCall }, _options = {}) {
24
+ const [restored, setRestored] = useState();
25
+ const [live, setLive] = useState(false);
26
+ const pending = useRef(undefined);
27
+ const answerController = useRef(undefined);
28
+ const completed = useRef(false);
29
+ const info = surfaceRecoveryInfo({ value: resource?.resource._meta?.[SURFACE_EXCHANGE_META] });
30
+ const caller = info ? recoveryCaller(onToolCall) : undefined;
31
+ const needsRecovery = surfaceNeedsRecovery({ call, runStreaming });
32
+ const onSurface = (emission) => {
33
+ if (emission.channel !== 'mcp-ui')
34
+ return;
35
+ const next = parseUIResource(recoverableSurfaceResource({ resource: emission.payload['resource'], exchange: emission.payload['surfaceExchange'] }));
36
+ if (next) {
37
+ setRestored(next);
38
+ setLive(true);
39
+ }
40
+ };
41
+ useEffect(() => {
42
+ if (!caller || !info || !needsRecovery)
43
+ return;
44
+ let disposed = false;
45
+ const restore = () => {
46
+ if (pending.current || disposed || completed.current)
47
+ return;
48
+ const controller = new AbortController();
49
+ pending.current = controller;
50
+ void caller.restore({ ...info, signal: controller.signal, onSurface: emission => { if (!disposed)
51
+ onSurface(emission); } })
52
+ .then(() => { if (!disposed)
53
+ completed.current = true; })
54
+ .catch(() => { })
55
+ .finally(() => { if (!disposed) {
56
+ pending.current = undefined;
57
+ setLive(false);
58
+ } });
59
+ };
60
+ restore();
61
+ window.addEventListener('focus', restore);
62
+ return () => {
63
+ disposed = true;
64
+ window.removeEventListener('focus', restore);
65
+ pending.current?.abort();
66
+ pending.current = undefined;
67
+ answerController.current?.abort();
68
+ answerController.current = undefined;
69
+ };
70
+ }, [caller, info?.exchangeId, needsRecovery]);
71
+ useEffect(() => () => answerController.current?.abort(), []);
72
+ const toolCall = caller ? async (submitted) => {
73
+ const controller = new AbortController();
74
+ answerController.current = controller;
75
+ try {
76
+ return await caller.answer({ call: submitted, signal: controller.signal, onSurface });
77
+ }
78
+ finally {
79
+ answerController.current = undefined;
80
+ }
81
+ } : onToolCall;
82
+ return { resource: restored ?? resource, restored: restored !== undefined, live, onToolCall: toolCall };
83
+ }
84
+ //# sourceMappingURL=useSurfaceRecovery.js.map
@@ -36,11 +36,10 @@
36
36
  }
37
37
 
38
38
  /* Lets a long agent name inside (e.g. an AgentRuntimePicker trigger) shrink and ellipsis instead
39
- of forcing the row wider — flex items default to min-width:auto, which blocks shrinking below
40
- content size unless every ancestor in the chain opts out. Docked into a narrow sidebar (< 400px)
41
- is exactly where this stops being cosmetic. */
39
+ of forcing the row wider, while retaining the fixed controls' intrinsic minimum. A zero floor
40
+ let the effort selector overflow this wrapper across the Send/Stop hit targets in a dock. */
42
41
  .jini-composer-footer-accessories {
43
- min-width: 0;
42
+ min-width: min-content;
44
43
  }
45
44
 
46
45
  /* The picker, plus menu and send share this <div>; flex keeps them on one line, and it wraps
@@ -48,7 +47,7 @@
48
47
  .jini-composer-footer-trailing {
49
48
  display: flex;
50
49
  align-items: center;
51
- min-width: 0;
50
+ min-width: min-content;
52
51
  margin-left: auto;
53
52
  }
54
53
 
@@ -165,7 +165,7 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
165
165
  const events = recoveredRunEvents({ saved: savedWork({ run }) }, { notice: missingResume ? 'silent' : 'visible' });
166
166
  if (!await ports.store.advance({ run, nextRunId, now: ports.now(), events }, {}))
167
167
  return "superseded";
168
- const next = await ports.store.load({ messageId: run.messageId }, {});
168
+ const next = await ports.store.load({ messageId: run.messageId }, { includeTranscriptEvents: false });
169
169
  // Cancel/deletion can win while the old attempt is being fenced. Re-read after CAS and do
170
170
  // not launch against a removed binding or a newer generation.
171
171
  if (!next || next.runId !== nextRunId)
@@ -234,7 +234,7 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
234
234
  }
235
235
  /** The saved row after an await, or the outcome that ends this decision. O(1) store reads. */
236
236
  async function reload(run) {
237
- const current = await ports.store.load({ messageId: run.messageId }, {});
237
+ const current = await ports.store.load({ messageId: run.messageId }, { includeTranscriptEvents: false });
238
238
  if (!current)
239
239
  return "gone";
240
240
  // A probe only speaks about the generation it inspected. Never apply its dead result to
@@ -264,7 +264,7 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
264
264
  }
265
265
  return {
266
266
  async recover({ messageId, trigger, expectedRunId }, options) {
267
- const run = await ports.store.load({ messageId }, {});
267
+ const run = await ports.store.load({ messageId }, { includeTranscriptEvents: false });
268
268
  if (!run)
269
269
  return "gone";
270
270
  if (run.message.runStatus !== "queued" && run.message.runStatus !== "running")
@@ -88,8 +88,22 @@ export interface ChatStore {
88
88
  delete(required: {
89
89
  readonly id: string;
90
90
  }, optional?: Record<string, never>): Promise<void>;
91
- /** Compatibility read: all messages in stored position order; unauthorized returns []. */
92
- messages(required: ChatMessagePageInput, optional?: Record<string, never>): Promise<ChatMessage[]>;
91
+ /** Compatibility read in stored order; unauthorized returns []. `false` omits event columns;
92
+ * `user` keeps input receipts/folders without reading prior assistant payloads. */
93
+ messages(required: ChatMessagePageInput, optional?: {
94
+ readonly includeEvents?: boolean | "user";
95
+ }): Promise<ChatMessage[]>;
96
+ /** Optional indexed compatibility read; unknown and other-owned rows both return null. */
97
+ message?(required: ChatMessagePageInput & {
98
+ readonly messageId: string;
99
+ }, optional?: Record<string, never>): Promise<ChatMessage | null>;
100
+ /** Cheap owner-scoped history validator; reads metadata, never message/event payloads. */
101
+ messagesVersion?(required: ChatMessagePageInput, optional?: Record<string, never>): Promise<string | null>;
102
+ /** JSON response chunks, preserving stored event JSON instead of serializing event object trees.
103
+ * The optional ordering policy sees message metadata and receipt-derived folders, without events. */
104
+ messagesJson?(required: ChatMessagePageInput, optional?: {
105
+ readonly orderMessages?: (messages: ChatMessage[]) => ChatMessage[];
106
+ }): Promise<AsyncIterable<string>>;
93
107
  /** Idempotent update by message ID within its original conversation; unauthorized: null. */
94
108
  appendMessage(required: {
95
109
  readonly conversationId: string;
@@ -100,7 +114,7 @@ export interface ChatStore {
100
114
  readonly search?: ChatSearchCapability;
101
115
  }
102
116
  /** Eight-method compatibility subset plus optional bounded history; old hosts/fakes still fit. */
103
- export type ChatHistoryStore = Pick<ChatStore, 'list' | 'get' | 'create' | 'rename' | 'touch' | 'delete' | 'messages' | 'appendMessage' | 'history'>;
117
+ export type ChatHistoryStore = Pick<ChatStore, 'list' | 'get' | 'create' | 'rename' | 'touch' | 'delete' | 'messages' | 'message' | 'messagesJson' | 'messagesVersion' | 'appendMessage' | 'history'>;
104
118
  /** Internal persistence projections. Model-facing tools redact and cap these before returning. */
105
119
  export interface ChatHistoryReader {
106
120
  list(required: Record<string, never>, optional?: {
@@ -1,3 +1,5 @@
1
+ import { inspect } from 'node:util';
2
+ import { redactSecrets } from '@jini-ai/core';
1
3
  import { ChatStoreError } from "../errors.js";
2
4
  /**
3
5
  * @file Retention for chat history: deletes every conversation whose `expires_at` has passed.
@@ -47,7 +49,7 @@ export function startChatExpirySweep({ maintenance }, options = {}) {
47
49
  const onError = options.onError ??
48
50
  ((error) => {
49
51
  // eslint-disable-next-line no-console
50
- console.warn(`[chat-expiry-sweep] sweep failed: ${error instanceof Error ? error.message : String(error)}`);
52
+ console.warn('[chat-expiry-sweep] sweep failed:', redactSecrets({ input: inspect(error) }, { redactPii: false }));
51
53
  });
52
54
  let active;
53
55
  const pass = () => {
@@ -50,6 +50,12 @@ export function createChatHistoryStore({ kernel: hostKernel, scope, isDaemonRunI
50
50
  async messages(required, optional = {}) {
51
51
  return repairLegacyTurnOrder({ messages: await store.messages(required, optional), isDaemonRunId }, {});
52
52
  },
53
+ // Raw history must apply the same repair before emitting chunks; ordering never needs events.
54
+ async messagesJson(required, optional = {}) {
55
+ return store.messagesJson(required, { ...optional,
56
+ orderMessages: messages => repairLegacyTurnOrder({ messages, isDaemonRunId }, {}),
57
+ });
58
+ },
53
59
  };
54
60
  }
55
61
  function storeFactory(transport) {
@@ -0,0 +1,35 @@
1
+ import type { AgentEvent } from "../../core/index.js";
2
+ import type { CheckpointState, RunProgress } from "../../core/durable-runs/ledger-contracts.js";
3
+ /** A delta may append events or replace the final coalesced text event. Every earlier event
4
+ * must match; replay reordering, thinking replacement and tool identity deduplication fall back.
5
+ * @complexity O(prefix bytes + new suffix bytes) time; O(new suffix bytes) temporary space. */
6
+ export declare function checkpointDelta({ previous, progress }: {
7
+ previous: CheckpointState;
8
+ progress: RunProgress;
9
+ }, _options?: {}): {
10
+ removeCharacters: number;
11
+ suffix: string;
12
+ grown: boolean;
13
+ } | undefined;
14
+ /** Same tool identity rule as the full checkpoint's progress check. O(saved + incoming events). */
15
+ export declare function hasNewToolProgress({ saved, incoming }: {
16
+ saved: readonly AgentEvent[];
17
+ incoming: readonly AgentEvent[];
18
+ }, _options?: {}): boolean;
19
+ /** Durable tool identity shared by the full merge and delta progress check. */
20
+ export declare function toolKey({ event }: {
21
+ event: AgentEvent;
22
+ }, _options?: {}): string;
23
+ /** Build a receipt only for an exact incoming projection; callers own its watch lifetime.
24
+ * SQL supplies content/event lengths; the removed tail counts Unicode code points. Retain only
25
+ * a bounded suffix for CAS, so a multi-MB tool result never becomes a guard parameter twice.
26
+ * @complexity O(last event bytes) serialization/counting; O(last event bytes) transient space,
27
+ * with at most 256 UTF-16 units retained beyond the existing projection references.
28
+ */
29
+ export declare function checkpointState({ progress, length, contentLength, version }: {
30
+ progress: RunProgress;
31
+ length: number;
32
+ contentLength: number;
33
+ version: string;
34
+ }, _options?: {}): CheckpointState;
35
+ //# sourceMappingURL=checkpoint-delta.d.ts.map