@jini-ai/chat 0.6.5 → 0.6.7

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 (161) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/core/accepted-input.d.ts +54 -0
  3. package/dist/core/accepted-input.js +165 -0
  4. package/dist/core/agent-plugin-chat-state.d.ts +43 -0
  5. package/dist/core/agent-plugin-chat-state.js +48 -0
  6. package/dist/core/assistant-client/a2ui-action-poster.js +32 -28
  7. package/dist/core/assistant-client/messages.d.ts +1 -1
  8. package/dist/core/assistant-client/messages.js +2 -2
  9. package/dist/core/assistant-client/transport.d.ts +2 -2
  10. package/dist/core/assistant-client/transport.js +184 -97
  11. package/dist/core/assistant-content.d.ts +2 -1
  12. package/dist/core/assistant-content.js +9 -11
  13. package/dist/core/browser/page-actions.js +4 -2
  14. package/dist/core/composer-history.d.ts +16 -9
  15. package/dist/core/composer-history.js +28 -16
  16. package/dist/core/conversation-title.d.ts +3 -0
  17. package/dist/core/conversation-title.js +3 -0
  18. package/dist/core/durable-projection.js +20 -13
  19. package/dist/core/durable-runs/continuation.d.ts +4 -0
  20. package/dist/core/durable-runs/continuation.js +35 -6
  21. package/dist/core/durable-runs/ledger-contracts.d.ts +2 -1
  22. package/dist/core/durable-runs/policies.d.ts +1 -0
  23. package/dist/core/durable-runs/policies.js +1 -0
  24. package/dist/core/durable-runs/ports.d.ts +6 -2
  25. package/dist/core/index.d.ts +2 -0
  26. package/dist/core/index.js +2 -0
  27. package/dist/core/messages.d.ts +2 -0
  28. package/dist/core/partial-json.d.ts +6 -7
  29. package/dist/core/partial-json.js +81 -47
  30. package/dist/core/persistence/title.js +4 -2
  31. package/dist/core/question-answer.d.ts +23 -0
  32. package/dist/core/question-answer.js +66 -0
  33. package/dist/core/question-form/format.d.ts +6 -6
  34. package/dist/core/question-form/format.js +16 -17
  35. package/dist/core/question-form/normalize.d.ts +18 -0
  36. package/dist/core/question-form/normalize.js +112 -122
  37. package/dist/core/question-form/scan.js +141 -203
  38. package/dist/core/run-activity.js +62 -49
  39. package/dist/core/run-events/entry.d.ts +10 -1
  40. package/dist/core/run-events/entry.js +117 -87
  41. package/dist/core/todos.d.ts +1 -1
  42. package/dist/core/todos.js +60 -85
  43. package/dist/core/tool-events.js +14 -12
  44. package/dist/core/transcript.js +62 -16
  45. package/dist/core/turn-usage.d.ts +2 -1
  46. package/dist/core/turn-usage.js +56 -27
  47. package/dist/core/user-text-redaction.js +3 -0
  48. package/dist/core/util/manifest.js +77 -82
  49. package/dist/core/util/markdown-context.js +51 -53
  50. package/dist/core/util/parser.js +136 -144
  51. package/dist/core/util/pointer.js +16 -16
  52. package/dist/core/util/strip.js +57 -67
  53. package/dist/core/util/validate.js +15 -17
  54. package/dist/embed-widget/browser/session-store.js +21 -12
  55. package/dist/embed-widget/transports/fetch-sse.js +108 -95
  56. package/dist/embed-widget/vanilla/index.js +81 -70
  57. package/dist/react/agent-bridge/frontend-session-bridge.js +80 -52
  58. package/dist/react/components/AttachmentPreviewModal.js +25 -1
  59. package/dist/react/components/ChatSettingsDialog.d.ts +13 -0
  60. package/dist/react/components/ChatSettingsDialog.hooks.d.ts +115 -0
  61. package/dist/react/components/ChatSettingsDialog.hooks.js +40 -0
  62. package/dist/react/components/ChatSettingsDialog.js +72 -0
  63. package/dist/react/components/ChatSettingsSegmented.d.ts +18 -0
  64. package/dist/react/components/ChatSettingsSegmented.js +11 -0
  65. package/dist/react/components/Composer.d.ts +5 -1
  66. package/dist/react/components/Composer.js +126 -82
  67. package/dist/react/components/ConversationList.js +25 -39
  68. package/dist/react/components/ConversationTitleEditor.d.ts +27 -0
  69. package/dist/react/components/ConversationTitleEditor.js +54 -0
  70. package/dist/react/components/Icon.d.ts +1 -1
  71. package/dist/react/components/Icon.js +25 -45
  72. package/dist/react/components/Markdown.js +134 -123
  73. package/dist/react/components/McpUiSurfaceCard.d.ts +2 -2
  74. package/dist/react/components/McpUiSurfaceCard.js +30 -9
  75. package/dist/react/components/MessageList.d.ts +13 -13
  76. package/dist/react/components/MessageList.hooks.d.ts +6 -1
  77. package/dist/react/components/MessageList.hooks.js +19 -3
  78. package/dist/react/components/MessageList.js +34 -10
  79. package/dist/react/components/MessageRow.js +48 -32
  80. package/dist/react/components/MessageRow.usage.d.ts +5 -2
  81. package/dist/react/components/MessageRow.usage.js +20 -11
  82. package/dist/react/components/MidRunUserMessage.js +16 -3
  83. package/dist/react/components/QuestionForm.js +148 -76
  84. package/dist/react/components/QuestionsPanel.js +1 -1
  85. package/dist/react/components/TodoCard.js +12 -6
  86. package/dist/react/components/ToolCard.js +47 -40
  87. package/dist/react/components/composer-discovery.js +6 -1
  88. package/dist/react/components/mcp-ui-surface-state.d.ts +6 -11
  89. package/dist/react/components/mcp-ui-surface-state.js +23 -8
  90. package/dist/react/ext-event-renderer-registry.d.ts +8 -0
  91. package/dist/react/ext-event-renderer-registry.js +5 -2
  92. package/dist/react/features/chat-pane/components/AgentPluginUsage.d.ts +7 -0
  93. package/dist/react/features/chat-pane/components/AgentPluginUsage.js +45 -0
  94. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +55 -19
  95. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  96. package/dist/react/features/chat-pane/components/ChatPane.js +192 -74
  97. package/dist/react/features/chat-pane/components/RuntimeDropUp.d.ts +29 -0
  98. package/dist/react/features/chat-pane/components/RuntimeDropUp.js +45 -0
  99. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +80 -47
  100. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +44 -40
  101. package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +6 -6
  102. package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -8
  103. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +3 -3
  104. package/dist/react/features/chat-pane/create-typed-answer-poster.js +3 -3
  105. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts +6 -1
  106. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +11 -5
  107. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +12 -7
  108. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +340 -149
  109. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +7 -6
  110. package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +28 -10
  111. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +3 -3
  112. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +60 -12
  113. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.d.ts +49 -0
  114. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +143 -0
  115. package/dist/react/features/chat-pane/model-options.d.ts +18 -1
  116. package/dist/react/features/chat-pane/model-options.js +49 -20
  117. package/dist/react/features/chat-pane/rules.d.ts +8 -9
  118. package/dist/react/features/chat-pane/rules.js +84 -46
  119. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  120. package/dist/react/features/chat-pane/styles.js +156 -37
  121. package/dist/react/features/chat-pane/types.d.ts +17 -8
  122. package/dist/react/hooks/assistant-chats.js +12 -5
  123. package/dist/react/hooks/composer-draft-cache.js +116 -118
  124. package/dist/react/hooks/composer-history-storage.js +5 -5
  125. package/dist/react/hooks/fab-position.js +7 -4
  126. package/dist/react/hooks/useAnsweredCards.d.ts +21 -4
  127. package/dist/react/hooks/useAnsweredCards.js +38 -11
  128. package/dist/react/hooks/useArtifactStream.js +28 -22
  129. package/dist/react/hooks/useComposer.d.ts +20 -0
  130. package/dist/react/hooks/useComposer.js +22 -9
  131. package/dist/react/hooks/useComposerHistory.hooks.d.ts +6 -1
  132. package/dist/react/hooks/useComposerHistory.hooks.js +39 -19
  133. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +24 -14
  134. package/dist/react/hooks/useConversation.d.ts +7 -0
  135. package/dist/react/hooks/useConversation.js +23 -10
  136. package/dist/react/hooks/useExtEventGroups.js +39 -39
  137. package/dist/react/hooks/useQuestionForms.js +21 -26
  138. package/dist/react/hooks/useRunStream.js +10 -2
  139. package/dist/react/index.d.ts +7 -1
  140. package/dist/react/index.js +9 -0
  141. package/dist/react/message-blocks.d.ts +6 -4
  142. package/dist/react/message-blocks.js +94 -72
  143. package/dist/react/slots.d.ts +2 -2
  144. package/dist/react/tool-call-display.js +19 -17
  145. package/dist/server/durable-runs/recover.d.ts +1 -1
  146. package/dist/server/durable-runs/recover.js +139 -76
  147. package/dist/server/run-finalizer/entry.js +25 -15
  148. package/dist/store/legacy/sqlite/conversations.d.ts +11 -11
  149. package/dist/store/legacy/sqlite/conversations.js +69 -98
  150. package/dist/store/legacy/sqlite/messages.d.ts +10 -2
  151. package/dist/store/legacy/sqlite/messages.js +113 -71
  152. package/dist/store/runtime/chat-history-store.js +9 -3
  153. package/dist/store/runtime/durable-run-store.d.ts +2 -2
  154. package/dist/store/runtime/durable-run-store.js +165 -117
  155. package/dist/store/runtime/history-tools.js +118 -82
  156. package/dist/store/runtime/run-ledger.d.ts +4 -1
  157. package/dist/store/runtime/run-ledger.js +56 -16
  158. package/dist/store/sql/cursor.js +38 -18
  159. package/dist/store/sql/store.d.ts +18 -0
  160. package/dist/store/sql/store.js +46 -15
  161. package/package.json +12 -10
@@ -1,4 +1,84 @@
1
1
  import { assistantContentFromEvents, isReplyBoundary, legacyAssistantContentFromEvents } from '../core/index.js';
2
+ /** Whether there is anything besides plain text to place: a tool row, an ext event, or (when asked
3
+ * for) non-blank thinking. With none of these the flat path is identical. */
4
+ function hasSomethingToInterleave({ events, rows }, { includeThinking }) {
5
+ if (rows.length > 0)
6
+ return true;
7
+ return events.some((ev) => ev.kind === 'ext' || (includeThinking && ev.kind === 'thinking' && ev.text.trim() !== ''));
8
+ }
9
+ /** Whether `content` is demonstrably built from the text events. */
10
+ function contentMatchesEvents({ events, content }) {
11
+ // Both producers' shapes count: today's (a paragraph break between steps, see
12
+ // `assistantContentFromEvents`) and the old plain concatenation older saved rows carry.
13
+ return content === assistantContentFromEvents({ events }) || content === legacyAssistantContentFromEvents({ events });
14
+ }
15
+ function flushText({ state }) {
16
+ if (state.buffer.length === 0)
17
+ return;
18
+ state.blocks.push({ kind: 'text', text: state.buffer, key: `text-${state.blocks.length}` });
19
+ state.buffer = '';
20
+ }
21
+ function placeThinking({ state, text }) {
22
+ flushText({ state });
23
+ const { blocks } = state;
24
+ const last = blocks[blocks.length - 1];
25
+ if (last && last.kind === 'thinking')
26
+ blocks[blocks.length - 1] = { kind: 'thinking', text: last.text + text, key: last.key };
27
+ else
28
+ blocks.push({ kind: 'thinking', text, key: `thinking-${blocks.length}` });
29
+ }
30
+ function placeReplyEnd({ state }) {
31
+ flushText({ state });
32
+ state.blocks.push({ kind: 'reply-end', index: state.replies, key: `reply-end-${state.replies}` });
33
+ state.replies += 1;
34
+ }
35
+ function placeExt({ state, ev }, { slotOf }) {
36
+ // One block per SLOT, at its first occurrence — later events in the same slot fold into it
37
+ // (`useExtEventGroups`'s job in `MessageRow.tsx`), not a second block. A slot is the `name`
38
+ // unless its renderer keys events into separate surfaces (see `ExtEventSlotKey`).
39
+ const slot = slotOf(ev);
40
+ if (state.emittedExtSlots.has(slot))
41
+ return;
42
+ state.emittedExtSlots.add(slot);
43
+ flushText({ state });
44
+ state.blocks.push({ kind: 'ext', name: ev.name, slot, key: `ext-${slot}` });
45
+ }
46
+ function placeToolUse({ state, id }) {
47
+ const row = state.rowById.get(id);
48
+ // A `tool_use` the timeline did not surface (deduped away as a repeat of one already seen).
49
+ // Skipping it keeps this function's output consistent with `rows`, which is the authority on
50
+ // what gets a card.
51
+ if (!row || state.emitted.has(id))
52
+ return;
53
+ state.emitted.add(id);
54
+ flushText({ state });
55
+ const { blocks } = state;
56
+ const last = blocks[blocks.length - 1];
57
+ if (last && last.kind === 'tools') {
58
+ blocks[blocks.length - 1] = { kind: 'tools', rows: [...last.rows, row], key: last.key };
59
+ }
60
+ else {
61
+ blocks.push({ kind: 'tools', rows: [row], key: `tools-${blocks.length}` });
62
+ }
63
+ }
64
+ function placeEvent({ state, ev }, { slotOf, includeThinking }) {
65
+ if (ev.kind === 'text') {
66
+ state.buffer += ev.text;
67
+ return;
68
+ }
69
+ if (ev.kind === 'thinking') {
70
+ if (includeThinking)
71
+ placeThinking({ state, text: ev.text });
72
+ return;
73
+ }
74
+ // Emitted per boundary, before any slot dedupe below, so every reply gets its own end marker.
75
+ if (isReplyBoundary({ event: ev }))
76
+ placeReplyEnd({ state });
77
+ if (ev.kind === 'ext')
78
+ placeExt({ state, ev }, { slotOf });
79
+ else if (ev.kind === 'tool_use')
80
+ placeToolUse({ state, id: ev.id });
81
+ }
2
82
  /**
3
83
  * Rebuilds an assistant message as ordered blocks, or returns `null` if that cannot be done
4
84
  * losslessly.
@@ -17,85 +97,27 @@ import { assistantContentFromEvents, isReplyBoundary, legacyAssistantContentFrom
17
97
  export function interleaveMessageBlocks({ events, content, rows }, { slotOf = (ev) => ev.name, includeThinking = false } = {}) {
18
98
  if (!events || events.length === 0)
19
99
  return null;
20
- const hasExtEvent = events.some((ev) => ev.kind === 'ext');
21
- const hasThinking = includeThinking && events.some((ev) => ev.kind === 'thinking' && ev.text.trim() !== '');
22
- if (rows.length === 0 && !hasExtEvent && !hasThinking)
100
+ if (!hasSomethingToInterleave({ events, rows }, { includeThinking }))
23
101
  return null;
24
102
  // The reconstruction is only trustworthy if the text events are demonstrably the source `content`
25
103
  // was built from. Anything else and the offsets below are fiction.
26
- // Both producers' shapes count: today's (a paragraph break between steps, see
27
- // `assistantContentFromEvents`) and the old plain concatenation older saved rows carry.
28
- if (content !== assistantContentFromEvents({ events }) && content !== legacyAssistantContentFromEvents({ events: events }))
104
+ if (!contentMatchesEvents({ events, content }))
29
105
  return null;
30
- const rowById = new Map(rows.map((row) => [row.id, row]));
31
- const blocks = [];
32
- const emitted = new Set();
33
- const emittedExtSlots = new Set();
34
- let buffer = '';
35
- let replies = 0;
36
- const flushText = () => {
37
- if (buffer.length === 0)
38
- return;
39
- blocks.push({ kind: 'text', text: buffer, key: `text-${blocks.length}` });
40
- buffer = '';
106
+ const state = {
107
+ rowById: new Map(rows.map((row) => [row.id, row])),
108
+ blocks: [],
109
+ emitted: new Set(),
110
+ emittedExtSlots: new Set(),
111
+ buffer: '',
112
+ replies: 0,
41
113
  };
42
- for (const ev of events) {
43
- if (ev.kind === 'text') {
44
- buffer += ev.text;
45
- continue;
46
- }
47
- if (ev.kind === 'thinking') {
48
- if (!includeThinking)
49
- continue;
50
- flushText();
51
- const last = blocks[blocks.length - 1];
52
- if (last && last.kind === 'thinking')
53
- blocks[blocks.length - 1] = { kind: 'thinking', text: last.text + ev.text, key: last.key };
54
- else
55
- blocks.push({ kind: 'thinking', text: ev.text, key: `thinking-${blocks.length}` });
56
- continue;
57
- }
58
- if (isReplyBoundary({ event: ev })) {
59
- // Emitted per boundary, before any slot dedupe below, so every reply gets its own end marker.
60
- flushText();
61
- blocks.push({ kind: 'reply-end', index: replies, key: `reply-end-${replies}` });
62
- replies += 1;
63
- }
64
- if (ev.kind === 'ext') {
65
- // One block per SLOT, at its first occurrence — later events in the same slot fold into it
66
- // (`useExtEventGroups`'s job in `MessageRow.tsx`), not a second block. A slot is the `name`
67
- // unless its renderer keys events into separate surfaces (see `ExtEventSlotKey`).
68
- const slot = slotOf(ev);
69
- if (emittedExtSlots.has(slot))
70
- continue;
71
- emittedExtSlots.add(slot);
72
- flushText();
73
- blocks.push({ kind: 'ext', name: ev.name, slot, key: `ext-${slot}` });
74
- continue;
75
- }
76
- if (ev.kind !== 'tool_use')
77
- continue;
78
- const row = rowById.get(ev.id);
79
- // A `tool_use` the timeline did not surface (deduped away as a repeat of one already seen).
80
- // Skipping it keeps this function's output consistent with `rows`, which is the authority on
81
- // what gets a card.
82
- if (!row || emitted.has(ev.id))
83
- continue;
84
- emitted.add(ev.id);
85
- flushText();
86
- const last = blocks[blocks.length - 1];
87
- if (last && last.kind === 'tools') {
88
- blocks[blocks.length - 1] = { kind: 'tools', rows: [...last.rows, row], key: last.key };
89
- }
90
- else {
91
- blocks.push({ kind: 'tools', rows: [row], key: `tools-${blocks.length}` });
92
- }
93
- }
94
- flushText();
114
+ for (const ev of events)
115
+ placeEvent({ state, ev }, { slotOf, includeThinking });
116
+ flushText({ state });
95
117
  // Every row must have found its place. If one did not, the message would render with a tool card
96
118
  // missing entirely — strictly worse than the flat layout it would have had.
97
- if (emitted.size !== rows.length)
119
+ if (state.emitted.size !== rows.length)
98
120
  return null;
99
- return blocks;
121
+ return state.blocks;
100
122
  }
101
123
  //# sourceMappingURL=message-blocks.js.map
@@ -179,8 +179,8 @@ export interface ComposerSlots {
179
179
  * Host controls rendered at the START of the footer row, immediately after the attach/discovery
180
180
  * "+" trigger — for a control that belongs with the composer's other action-row buttons (e.g. a
181
181
  * push-to-talk mic button) rather than in {@link leadingAccessories}'s pinned-context zone above
182
- * the input, or in {@link footerAccessories}'s trailing slot (reserved for an agent/model picker
183
- * and pushed to the row's far end via `margin-left: auto`).
182
+ * the input, or in {@link footerAccessories}'s slot (reserved for an agent/model picker, which
183
+ * follows this one; only the send group is pushed to the row's far end).
184
184
  */
185
185
  footerLeadingAccessory?: ReactNode;
186
186
  onAttach?: (a: ChatAttachment) => void;
@@ -30,34 +30,36 @@ function isRunningToolRow(row) {
30
30
  export function applyToolCallDisplay({ blocks, mode }) {
31
31
  if (mode === 'show')
32
32
  return blocks;
33
- if (mode === 'hidden') {
34
- const out = [];
35
- for (const block of blocks) {
36
- if (block.kind !== 'tools') {
37
- out.push(block);
38
- continue;
39
- }
40
- const failed = block.rows.filter((row) => isFailedToolRow({ row }));
41
- if (failed.length > 0)
42
- out.push({ kind: 'tools', rows: failed, key: block.key });
33
+ return mode === 'hidden' ? hideToolCalls({ blocks }) : collapseToolCalls({ blocks });
34
+ }
35
+ /** `'hidden'`: keeps only failed rows and drops `tools` blocks left empty. */
36
+ function hideToolCalls({ blocks }) {
37
+ const out = [];
38
+ for (const block of blocks) {
39
+ if (block.kind !== 'tools') {
40
+ out.push(block);
41
+ continue;
43
42
  }
44
- return out;
43
+ const failed = block.rows.filter((row) => isFailedToolRow({ row }));
44
+ if (failed.length > 0)
45
+ out.push({ kind: 'tools', rows: failed, key: block.key });
45
46
  }
47
+ return out;
48
+ }
49
+ /** `'collapsed'`: merges every `tools` block of a turn into the turn's first one (see above). */
50
+ function collapseToolCalls({ blocks }) {
46
51
  const out = [];
47
52
  let turnToolsIndex = -1;
48
53
  for (const block of blocks) {
49
54
  if (block.kind === 'reply-end')
50
55
  turnToolsIndex = -1;
51
- if (block.kind !== 'tools') {
52
- out.push(block);
53
- continue;
54
- }
55
56
  const first = turnToolsIndex === -1 ? undefined : out[turnToolsIndex];
56
- if (first && first.kind === 'tools') {
57
+ if (block.kind === 'tools' && first?.kind === 'tools') {
57
58
  out[turnToolsIndex] = { kind: 'tools', rows: [...first.rows, ...block.rows], key: first.key };
58
59
  continue;
59
60
  }
60
- turnToolsIndex = out.length;
61
+ if (block.kind === 'tools')
62
+ turnToolsIndex = out.length;
61
63
  out.push(block);
62
64
  }
63
65
  return out;
@@ -1,5 +1,5 @@
1
1
  import type { DurableRecovery, RecoveryPorts } from "../../core/durable-runs/ports.js";
2
- export { RECOVERY_LIMIT, RECOVERY_WINDOW_MS, RUN_STALENESS_MS, SAVED_WORK_NOTICE, EXHAUSTED_NOTICE } from "../../core/durable-runs/policies.js";
2
+ export { RECOVERY_LIMIT, RECOVERY_WINDOW_MS, RUN_STALENESS_MS, SAVED_WORK_NOTICE, AWAITING_ANSWER_NOTICE, EXHAUSTED_NOTICE } from "../../core/durable-runs/policies.js";
3
3
  /** Recovery coordinator behind explicit host ports. One CAS owns both the attempt and its conversation slot.
4
4
  * Each recovery decision logs one `logger.info` line (default console) of run metadata. */
5
5
  export declare function createDurableRecovery(ports: RecoveryPorts, { logger }?: {
@@ -1,11 +1,39 @@
1
1
  import { recoveredRunEvents } from "../../core/index.js";
2
2
  import { classifyAgentServiceFailure } from "@jini-ai/agent-runtime";
3
3
  import { continuationRequest } from "../../core/durable-runs/continuation.js";
4
- import { RECOVERY_LIMIT, RECOVERY_WINDOW_MS, RUN_STALENESS_MS, SAVED_WORK_NOTICE, EXHAUSTED_NOTICE } from "../../core/durable-runs/policies.js";
5
- export { RECOVERY_LIMIT, RECOVERY_WINDOW_MS, RUN_STALENESS_MS, SAVED_WORK_NOTICE, EXHAUSTED_NOTICE } from "../../core/durable-runs/policies.js";
4
+ import { isAwaitingAnswer } from "../../core/run-activity.js";
5
+ import { RECOVERY_LIMIT, RECOVERY_WINDOW_MS, RUN_STALENESS_MS, SAVED_WORK_NOTICE, AWAITING_ANSWER_NOTICE, EXHAUSTED_NOTICE } from "../../core/durable-runs/policies.js";
6
+ export { RECOVERY_LIMIT, RECOVERY_WINDOW_MS, RUN_STALENESS_MS, SAVED_WORK_NOTICE, AWAITING_ANSWER_NOTICE, EXHAUSTED_NOTICE } from "../../core/durable-runs/policies.js";
6
7
  // translateRunFrame saves a run error frame as a status label holding only its message: the
7
8
  // daemon's MCP_BRIDGE_UNAVAILABLE code is dropped, so the bridge failure is matched by its text.
8
9
  const MCP_BRIDGE_FAILURE = /The assistant's tools did not load \(MCP bridge "jini" status: [^\r\n]*/;
10
+ const STOPPED_NOTICE = "Stopped. Saved work is above.";
11
+ /** Index of the last event matching `match`, or -1.
12
+ * @complexity O(e) time, O(1) space, no effects. */
13
+ function lastIndexWhere({ events, match }, _optional = {}) {
14
+ // Reverse scan preserves the last-marker rule on the desktop's ES2022 target.
15
+ for (let index = events.length - 1; index >= 0; index--)
16
+ if (match(events[index]))
17
+ return index;
18
+ return -1;
19
+ }
20
+ /** One saved event's contribution to the executor diagnostics. */
21
+ function diagnosticLines({ event }, _optional = {}) {
22
+ if (event.kind === "status")
23
+ return [event.label, event.detail ?? ""];
24
+ if (event.kind !== "raw")
25
+ return [];
26
+ try {
27
+ const result = JSON.parse(event.line);
28
+ // Successful CLI summaries contain usage metadata and answer prose, not failure evidence.
29
+ if (result?.type === "result" && result.is_error === false)
30
+ return [];
31
+ }
32
+ catch {
33
+ // Non-JSON stdout can still contain the executor's failure reason.
34
+ }
35
+ return [event.line];
36
+ }
9
37
  /** Retryability is independent of session resumability. Inspect executor diagnostics, never
10
38
  * answer text or tool results: those can quote unrelated failures from completed work. Old
11
39
  * attempt diagnostics remain visible but cannot decide the fate of their continuation. */
@@ -13,51 +41,36 @@ function attemptDiagnostics({ run }, _optional = {}) {
13
41
  const events = run.message.events ?? [];
14
42
  // Checkpoints merge adjacent text, so a base's event count is not a durable offset. The
15
43
  // coordinator's recovery marker separates attempts and survives that compaction.
16
- let marker = -1;
17
- // Reverse scan preserves the last-marker rule on the desktop's ES2022 target.
18
- for (let index = run.attemptBase.length ? events.length - 1 : -1; index >= 0; index--) {
19
- const event = events[index];
20
- if (event.kind === "status" && event.code === "run_recovering") {
21
- marker = index;
22
- break;
23
- }
24
- }
25
- return events.slice(marker >= 0 ? marker + 1 : run.attemptBase.length).flatMap((event) => {
26
- if (event.kind === "status")
27
- return [event.label, event.detail ?? ""];
28
- if (event.kind !== "raw")
29
- return [];
30
- try {
31
- const result = JSON.parse(event.line);
32
- // Successful CLI summaries contain usage metadata and answer prose, not failure evidence.
33
- if (result?.type === "result" && result.is_error === false)
34
- return [];
35
- }
36
- catch {
37
- // Non-JSON stdout can still contain the executor's failure reason.
38
- }
39
- return [event.line];
40
- }).join("\n");
44
+ const marker = run.attemptBase.length
45
+ ? lastIndexWhere({ events, match: (event) => event.kind === "status" && event.code === "run_recovering" })
46
+ : -1;
47
+ return events.slice(marker >= 0 ? marker + 1 : run.attemptBase.length).flatMap((event) => diagnosticLines({ event })).join("\n");
41
48
  }
42
- function attemptFailure(run) {
43
- const diagnostics = attemptDiagnostics({ run }, {});
49
+ /** Quota/billing and bad requests need human repair. A 429 for rate throttling and a 5xx
50
+ * overload remain retryable; the provider's coarse RATE_LIMITED class also includes billing. */
51
+ function needsHumanRepair({ diagnostics }, _optional = {}) {
52
+ const requestStatuses = diagnostics.matchAll(/\b(?:http(?:[ /]?\d(?:\.\d)?)?|(?:status|error|response)(?:[ _-]?code)?|code(?=\s*[:=#]))[\s:=#-]*(4\d\d)\b/gi);
53
+ if ([...requestStatuses].some((match) => match[1] !== "408" && match[1] !== "429"))
54
+ return true;
55
+ return /another answer in this chat is still running|invalid[_ -]?request|context[_ -](length|window)|maximum context|model.*(?:not[_ -]found|does not exist|not supported)|unsupported|does not support|permission denied|forbidden|insufficient[_ -]?(quota|balance|credit|funds)|credit balance is too low|exceeded your current quota|quota[_ -]?exceeded|usage limit|billing|payment required/i.test(diagnostics);
56
+ }
57
+ function attemptFailure({ diagnostics }, _optional = {}) {
44
58
  // MCP_BRIDGE_UNAVAILABLE needs bridge repair; another attempt repeats the same failure.
45
59
  // Classify it before interpreting the bridge's stderr as an agent auth/watchdog failure.
46
60
  if (MCP_BRIDGE_FAILURE.test(diagnostics))
47
61
  return "permanent";
48
62
  if (/inactivity[_ -]?(timeout|watchdog)|watchdog.*(kill|stop)/i.test(diagnostics))
49
63
  return "inactivity-watchdog";
50
- const service = classifyAgentServiceFailure({ text: diagnostics });
51
- if (service === "AGENT_AUTH_REQUIRED")
64
+ if (classifyAgentServiceFailure({ text: diagnostics }) === "AGENT_AUTH_REQUIRED")
52
65
  return "auth-failure";
53
- // Quota/billing and bad requests need human repair. A 429 for rate throttling and a 5xx
54
- // overload remain retryable; the provider's coarse RATE_LIMITED class also includes billing.
55
- const requestStatuses = diagnostics.matchAll(/\b(?:http(?:[ /]?\d(?:\.\d)?)?|(?:status|error|response)(?:[ _-]?code)?|code(?=\s*[:=#]))[\s:=#-]*(4\d\d)\b/gi);
56
- if ([...requestStatuses].some((match) => match[1] !== "408" && match[1] !== "429"))
57
- return "permanent";
58
- if (/another answer in this chat is still running|invalid[_ -]?request|context[_ -](length|window)|maximum context|model.*(?:not[_ -]found|does not exist|not supported)|unsupported|does not support|permission denied|forbidden|insufficient[_ -]?(quota|balance|credit|funds)|credit balance is too low|exceeded your current quota|quota[_ -]?exceeded|usage limit|billing|payment required/i.test(diagnostics))
59
- return "permanent";
60
- return "retryable";
66
+ return needsHumanRepair({ diagnostics }) ? "permanent" : "retryable";
67
+ }
68
+ /** Keep the notice bounded and scoped to the same executor diagnostics as failure classification. */
69
+ function lastDiagnosticLine({ diagnostics }, _optional = {}) {
70
+ return diagnostics.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1)?.slice(0, 300);
71
+ }
72
+ function attemptFailureDetail({ run }, _optional = {}) {
73
+ return lastDiagnosticLine({ diagnostics: attemptDiagnostics({ run }) });
61
74
  }
62
75
  function exclusion(run) {
63
76
  if (run.cancelReason)
@@ -66,23 +79,56 @@ function exclusion(run) {
66
79
  return "excluded";
67
80
  return null;
68
81
  }
82
+ /** Preflight cards precede model tools, so the normal open-tool activity scan cannot see them.
83
+ * Chat saves the bare resource without its wire toolUseId. Saved consent or subsequent model
84
+ * execution closes this preflight wait, including cards saved before this fix.
85
+ * A model tool closes the wait and no later card can reopen it, so the wait is pending exactly
86
+ * when no tool ran and the last card follows the last consent for this conversation.
87
+ * @complexity O(e) time, O(1) space, no effects.
88
+ */
89
+ function awaitingAnswer({ run }, _options = {}) {
90
+ if (isAwaitingAnswer({ events: run.message.events }))
91
+ return true;
92
+ const events = run.message.events ?? [];
93
+ if (events.some((event) => event.kind === "tool_use"))
94
+ return false;
95
+ const card = lastIndexWhere({ events, match: (event) => event.kind === "ext" && event.name === "mcp-ui" });
96
+ const consent = lastIndexWhere({ events, match: (event) => event.kind === "ext" && event.name === "agent_plugins"
97
+ && event.data?.conversationId === run.conversationId });
98
+ return card > consent;
99
+ }
69
100
  function budgetNotice(run, now) {
70
101
  if (now - run.lastProgressAt >= RUN_STALENESS_MS)
71
- return SAVED_WORK_NOTICE;
102
+ return awaitingAnswer({ run }) ? AWAITING_ANSWER_NOTICE : SAVED_WORK_NOTICE;
72
103
  if (run.recoveryCount >= RECOVERY_LIMIT)
73
104
  return EXHAUSTED_NOTICE;
74
105
  if (run.recoveryDeadline !== null && now >= run.recoveryDeadline)
75
106
  return EXHAUSTED_NOTICE;
76
107
  return null;
77
108
  }
109
+ /** The exhausted notice's detail: the attempt's last diagnostic, else which budget ended. */
110
+ function exhaustedDetail({ run }, _optional = {}) {
111
+ return attemptFailureDetail({ run }) ?? (run.recoveryCount >= RECOVERY_LIMIT ? `Tried ${RECOVERY_LIMIT} times` : "Recovery window ended");
112
+ }
113
+ /** Saved work to carry into the next attempt, seeding the saved content when no text event holds it. */
114
+ function savedWork({ run }, _optional = {}) {
115
+ const saved = [...(run.message.events ?? [])];
116
+ if (!saved.some((event) => event.kind === "text") && run.message.content)
117
+ saved.unshift({ kind: "text", text: run.message.content });
118
+ return saved;
119
+ }
120
+ /** A dead attempt continues now; a fresh queued one waits (see the caller's comment). */
121
+ function continuesNow({ run, probe, trigger, now }, _optional = {}) {
122
+ return probe === "dead" && (trigger === "attempt-failed" || now - run.attemptStartedAt >= 10_000);
123
+ }
78
124
  /** Recovery coordinator behind explicit host ports. One CAS owns both the attempt and its conversation slot.
79
125
  * Each recovery decision logs one `logger.info` line (default console) of run metadata. */
80
126
  export function createDurableRecovery(ports, { logger = console } = {}) {
81
- async function finalize(run, notice, canceled = false) {
127
+ async function finalize(run, notice, { canceled = false, detail } = {}) {
82
128
  await ports.cancelAttempt(run, {}).catch(() => undefined);
83
129
  // Persist the notice as an event: hook errors are live-only and would leave a reloaded
84
130
  // terminal message without its explanation. Only this coordinator decides exhaustion.
85
- const events = [...(run.message.events ?? []), { kind: "status", label: notice }];
131
+ const events = [...(run.message.events ?? []), { kind: "status", label: notice, ...(detail ? { detail } : {}) }];
86
132
  await ports.settle({ ...run, content: run.message.content, events, status: canceled ? "canceled" : "failed", endedAt: ports.now() }, {});
87
133
  return "finalized";
88
134
  }
@@ -93,6 +139,16 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
93
139
  return false;
94
140
  return ports.verifyChildDead(run.child, {}).catch(() => false);
95
141
  }
142
+ async function launchNext({ run, next, native }, _optional = {}) {
143
+ try {
144
+ await ports.launch({ run: next, request: continuationRequest({ run: { ...run, message: next.message, transcript: next.transcript }, native }, {}) }, {});
145
+ await ports.store.recoveryClock({ runId: next.runId, now: ports.now(), active: false }, {});
146
+ }
147
+ catch {
148
+ // A rejected/lost start response is uncertain: the daemon may already be executing. The
149
+ // same idempotent attempt is probed by the attached watcher before another CAS is possible.
150
+ }
151
+ }
96
152
  async function continueDead(run) {
97
153
  await ports.cancelAttempt(run, {}).catch(() => undefined);
98
154
  // A locator captured before a hard cancel can outlive its rollout. Reuse reconstruction,
@@ -100,10 +156,7 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
100
156
  const missingResume = run.request.agentId === 'codex' && /no rollout found for thread id/.test(attemptDiagnostics({ run }, {}));
101
157
  const native = !missingResume && await nativeAllowed(run);
102
158
  const nextRunId = ports.mintRunId();
103
- const saved = [...(run.message.events ?? [])];
104
- if (!saved.some((event) => event.kind === "text") && run.message.content)
105
- saved.unshift({ kind: "text", text: run.message.content });
106
- const events = recoveredRunEvents({ saved }, { notice: missingResume ? 'silent' : 'visible' });
159
+ const events = recoveredRunEvents({ saved: savedWork({ run }) }, { notice: missingResume ? 'silent' : 'visible' });
107
160
  if (!await ports.store.advance({ run, nextRunId, now: ports.now(), events }, {}))
108
161
  return "superseded";
109
162
  const next = await ports.store.load({ messageId: run.messageId }, {});
@@ -112,39 +165,46 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
112
165
  if (!next || next.runId !== nextRunId)
113
166
  return "superseded";
114
167
  if (next.cancelReason)
115
- return finalize(next, "Stopped. Saved work is above.", true);
116
- try {
117
- await ports.launch({ run: next, request: continuationRequest({ run: { ...run, message: next.message, transcript: next.transcript }, native }, {}) }, {});
118
- await ports.store.recoveryClock({ runId: next.runId, now: ports.now(), active: false }, {});
119
- }
120
- catch {
121
- // A rejected/lost start response is uncertain: the daemon may already be executing. The
122
- // same idempotent attempt is probed by the attached watcher before another CAS is possible.
123
- }
168
+ return finalize(next, STOPPED_NOTICE, { canceled: true });
169
+ await launchNext({ run, next, native });
124
170
  ports.attach(next, {});
125
171
  return "continued";
126
172
  }
127
- async function decide(run, trigger, liveRunId, seen) {
128
- if (exclusion(run))
129
- return finalize(run, "Stopped. Saved work is above.", true);
173
+ async function probeAttempt({ run, trigger, liveRunId }, _optional = {}) {
130
174
  // A terminal executor end confirms this attempt is dead even when the daemon retains its
131
175
  // replayable record (HTTP 200). Other triggers must obtain fresh liveness evidence.
132
- const probe = seen.probe = trigger === "attempt-failed" ? "dead" : liveRunId === run.runId ? "live" : await ports.probe(run, {});
133
- if (probe === "live") {
134
- await ports.store.recoveryClock({ runId: run.runId, now: ports.now(), active: false }, {});
135
- ports.attach(run, {});
136
- return "reattached";
137
- }
176
+ if (trigger === "attempt-failed")
177
+ return "dead";
178
+ return liveRunId === run.runId ? "live" : ports.probe(run, {});
179
+ }
180
+ async function reattach(run) {
181
+ await ports.store.recoveryClock({ runId: run.runId, now: ports.now(), active: false }, {});
182
+ ports.attach(run, {});
183
+ return "reattached";
184
+ }
185
+ async function finalizeFailure({ run, failure, diagnostics }, _optional = {}) {
186
+ if (failure !== "permanent")
187
+ return finalize(run, failure === "auth-failure" ? "Not logged in. Saved work is above." : STOPPED_NOTICE, { canceled: true });
188
+ // A bridge failure shows its own reason (and stderr): the generic notice hides what needs repair.
189
+ const bridgeNotice = diagnostics.match(MCP_BRIDGE_FAILURE)?.[0];
190
+ return finalize(run, bridgeNotice ?? EXHAUSTED_NOTICE, { detail: bridgeNotice ? undefined : lastDiagnosticLine({ diagnostics }) });
191
+ }
192
+ async function decide(run, trigger, liveRunId) {
193
+ if (exclusion(run))
194
+ return { decision: await finalize(run, STOPPED_NOTICE, { canceled: true }) };
195
+ const probe = await probeAttempt({ run, trigger, liveRunId });
196
+ if (probe === "live")
197
+ return { probe, decision: await reattach(run) };
198
+ // A pending human answer takes precedence over executor exit diagnostics.
199
+ if (probe === "dead" && awaitingAnswer({ run }))
200
+ return { probe, decision: await resolveMissing(run, probe, trigger) };
138
201
  // Diagnostics decide only the fate of an attempt that is no longer running. Read before the
139
202
  // probe, raw stdout quoting a tool description ("OAuth-authenticated") canceled a live turn
140
203
  // as "Not logged in" (luvira, 2026-10-08).
141
- const failure = seen.failure = attemptFailure(run);
142
- if (failure === "auth-failure" || failure === "inactivity-watchdog")
143
- return finalize(run, failure === "auth-failure" ? "Not logged in. Saved work is above." : "Stopped. Saved work is above.", true);
144
- // A bridge failure shows its own reason (and stderr): the generic notice hides what needs repair.
145
- if (failure === "permanent")
146
- return finalize(run, attemptDiagnostics({ run }, {}).match(MCP_BRIDGE_FAILURE)?.[0] ?? EXHAUSTED_NOTICE);
147
- return resolveMissing(run, probe, trigger);
204
+ const diagnostics = attemptDiagnostics({ run });
205
+ const failure = attemptFailure({ diagnostics });
206
+ const decision = failure === "retryable" ? await resolveMissing(run, probe, trigger) : await finalizeFailure({ run, failure, diagnostics });
207
+ return { probe, failure, decision };
148
208
  }
149
209
  async function resolveMissing(run, probe, trigger) {
150
210
  await ports.store.recoveryClock({ runId: run.runId, now: ports.now(), active: true }, {});
@@ -153,12 +213,16 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
153
213
  // a newer attempt committed by another caller while this one was awaiting storage/HTTP.
154
214
  if (timed.runId !== run.runId)
155
215
  return "superseded";
216
+ // A dead process cannot resume its question. End this turn with the existing card intact,
217
+ // rather than minting an attempt that runs preflight and asks the same question again.
218
+ if (probe === "dead" && awaitingAnswer({ run: timed }))
219
+ return finalize(timed, AWAITING_ANSWER_NOTICE);
156
220
  const notice = budgetNotice(timed, ports.now());
157
221
  if (notice)
158
- return finalize(run, notice);
222
+ return finalize(run, notice, { detail: notice === EXHAUSTED_NOTICE ? exhaustedDetail({ run: timed }) : undefined });
159
223
  // A queued attempt may be between durable acceptance and the daemon's start transaction.
160
224
  // Give that idempotent launch time to appear before a 404 can advance its generation.
161
- if (probe === "dead" && (trigger === "attempt-failed" || ports.now() - run.attemptStartedAt >= 10_000))
225
+ if (continuesNow({ run, probe, trigger, now: ports.now() }))
162
226
  return continueDead(timed);
163
227
  ports.attach(run, {});
164
228
  return "waiting";
@@ -172,12 +236,11 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
172
236
  return "terminal";
173
237
  if (expectedRunId && expectedRunId !== run.runId)
174
238
  return "superseded";
175
- const seen = {};
176
- const decision = await decide(run, trigger, options.liveRunId, seen);
239
+ const { decision, probe, failure } = await decide(run, trigger, options.liveRunId);
177
240
  // The watcher re-confirms its own live run every resolution window; log only real recovery
178
241
  // decisions. Metadata only: diagnostics, answers and cancel reasons can hold secrets.
179
242
  if (options.liveRunId !== run.runId)
180
- logger.info("[durable-recovery] " + JSON.stringify({ runId: run.runId, trigger, probe: seen.probe, decision, failureClass: seen.failure }));
243
+ logger.info("[durable-recovery] " + JSON.stringify({ runId: run.runId, trigger, probe, decision, failureClass: failure }));
181
244
  return decision;
182
245
  },
183
246
  };