@jini-ai/chat 0.6.6 → 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.
- package/CHANGELOG.md +4 -0
- package/dist/core/accepted-input.d.ts +54 -0
- package/dist/core/accepted-input.js +165 -0
- package/dist/core/agent-plugin-chat-state.d.ts +43 -0
- package/dist/core/agent-plugin-chat-state.js +48 -0
- package/dist/core/assistant-client/a2ui-action-poster.js +32 -28
- package/dist/core/assistant-client/messages.d.ts +1 -1
- package/dist/core/assistant-client/messages.js +2 -2
- package/dist/core/assistant-client/transport.d.ts +2 -2
- package/dist/core/assistant-client/transport.js +184 -97
- package/dist/core/assistant-content.d.ts +2 -1
- package/dist/core/assistant-content.js +9 -11
- package/dist/core/browser/page-actions.js +4 -2
- package/dist/core/composer-history.d.ts +16 -9
- package/dist/core/composer-history.js +28 -16
- package/dist/core/conversation-title.d.ts +3 -0
- package/dist/core/conversation-title.js +3 -0
- package/dist/core/durable-projection.js +20 -13
- package/dist/core/durable-runs/continuation.d.ts +4 -0
- package/dist/core/durable-runs/continuation.js +31 -6
- package/dist/core/durable-runs/ledger-contracts.d.ts +2 -1
- package/dist/core/durable-runs/ports.d.ts +6 -2
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.js +2 -0
- package/dist/core/messages.d.ts +2 -0
- package/dist/core/partial-json.d.ts +6 -7
- package/dist/core/partial-json.js +81 -47
- package/dist/core/persistence/title.js +4 -2
- package/dist/core/question-answer.d.ts +23 -0
- package/dist/core/question-answer.js +66 -0
- package/dist/core/question-form/format.d.ts +6 -6
- package/dist/core/question-form/format.js +16 -17
- package/dist/core/question-form/normalize.d.ts +18 -0
- package/dist/core/question-form/normalize.js +112 -122
- package/dist/core/question-form/scan.js +141 -203
- package/dist/core/run-activity.js +62 -49
- package/dist/core/run-events/entry.d.ts +10 -1
- package/dist/core/run-events/entry.js +117 -87
- package/dist/core/todos.d.ts +1 -1
- package/dist/core/todos.js +60 -85
- package/dist/core/tool-events.js +14 -12
- package/dist/core/transcript.js +62 -16
- package/dist/core/turn-usage.js +54 -32
- package/dist/core/user-text-redaction.js +3 -0
- package/dist/core/util/manifest.js +77 -82
- package/dist/core/util/markdown-context.js +51 -53
- package/dist/core/util/parser.js +136 -144
- package/dist/core/util/pointer.js +16 -16
- package/dist/core/util/strip.js +57 -67
- package/dist/core/util/validate.js +15 -17
- package/dist/embed-widget/browser/session-store.js +21 -12
- package/dist/embed-widget/transports/fetch-sse.js +108 -95
- package/dist/embed-widget/vanilla/index.js +81 -70
- package/dist/react/agent-bridge/frontend-session-bridge.js +80 -52
- package/dist/react/components/AttachmentPreviewModal.js +25 -1
- package/dist/react/components/ChatSettingsDialog.d.ts +13 -0
- package/dist/react/components/ChatSettingsDialog.hooks.d.ts +115 -0
- package/dist/react/components/ChatSettingsDialog.hooks.js +40 -0
- package/dist/react/components/ChatSettingsDialog.js +72 -0
- package/dist/react/components/ChatSettingsSegmented.d.ts +18 -0
- package/dist/react/components/ChatSettingsSegmented.js +11 -0
- package/dist/react/components/Composer.d.ts +5 -1
- package/dist/react/components/Composer.js +126 -82
- package/dist/react/components/ConversationList.js +25 -39
- package/dist/react/components/ConversationTitleEditor.d.ts +27 -0
- package/dist/react/components/ConversationTitleEditor.js +54 -0
- package/dist/react/components/Icon.d.ts +1 -1
- package/dist/react/components/Icon.js +25 -45
- package/dist/react/components/Markdown.js +134 -123
- package/dist/react/components/McpUiSurfaceCard.d.ts +2 -2
- package/dist/react/components/McpUiSurfaceCard.js +30 -9
- package/dist/react/components/MessageList.d.ts +13 -13
- package/dist/react/components/MessageList.hooks.d.ts +6 -1
- package/dist/react/components/MessageList.hooks.js +19 -3
- package/dist/react/components/MessageList.js +34 -10
- package/dist/react/components/MessageRow.js +47 -31
- package/dist/react/components/MessageRow.usage.js +16 -11
- package/dist/react/components/MidRunUserMessage.js +16 -3
- package/dist/react/components/QuestionForm.js +148 -76
- package/dist/react/components/QuestionsPanel.js +1 -1
- package/dist/react/components/TodoCard.js +12 -6
- package/dist/react/components/ToolCard.js +47 -40
- package/dist/react/components/composer-discovery.js +6 -1
- package/dist/react/components/mcp-ui-surface-state.d.ts +6 -11
- package/dist/react/components/mcp-ui-surface-state.js +23 -8
- package/dist/react/ext-event-renderer-registry.d.ts +8 -0
- package/dist/react/ext-event-renderer-registry.js +5 -2
- package/dist/react/features/chat-pane/components/AgentPluginUsage.d.ts +7 -0
- package/dist/react/features/chat-pane/components/AgentPluginUsage.js +45 -0
- package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +55 -19
- package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
- package/dist/react/features/chat-pane/components/ChatPane.js +192 -74
- package/dist/react/features/chat-pane/components/RuntimeDropUp.d.ts +29 -0
- package/dist/react/features/chat-pane/components/RuntimeDropUp.js +45 -0
- package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +80 -47
- package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +44 -40
- package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +6 -6
- package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -8
- package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +3 -3
- package/dist/react/features/chat-pane/create-typed-answer-poster.js +3 -3
- package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts +6 -1
- package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +11 -5
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +12 -7
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +340 -149
- package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +7 -6
- package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +28 -10
- package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +3 -3
- package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +60 -12
- package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.d.ts +49 -0
- package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +143 -0
- package/dist/react/features/chat-pane/model-options.d.ts +14 -0
- package/dist/react/features/chat-pane/model-options.js +45 -25
- package/dist/react/features/chat-pane/rules.d.ts +8 -9
- package/dist/react/features/chat-pane/rules.js +84 -46
- package/dist/react/features/chat-pane/styles.d.ts +1 -1
- package/dist/react/features/chat-pane/styles.js +152 -36
- package/dist/react/features/chat-pane/types.d.ts +17 -8
- package/dist/react/hooks/assistant-chats.js +12 -5
- package/dist/react/hooks/composer-draft-cache.js +116 -118
- package/dist/react/hooks/composer-history-storage.js +5 -5
- package/dist/react/hooks/fab-position.js +7 -4
- package/dist/react/hooks/useAnsweredCards.d.ts +21 -4
- package/dist/react/hooks/useAnsweredCards.js +38 -11
- package/dist/react/hooks/useArtifactStream.js +28 -22
- package/dist/react/hooks/useComposer.d.ts +20 -0
- package/dist/react/hooks/useComposer.js +22 -9
- package/dist/react/hooks/useComposerHistory.hooks.d.ts +6 -1
- package/dist/react/hooks/useComposerHistory.hooks.js +39 -19
- package/dist/react/hooks/useComposerHistoryKeys.hooks.js +24 -14
- package/dist/react/hooks/useConversation.d.ts +7 -0
- package/dist/react/hooks/useConversation.js +23 -10
- package/dist/react/hooks/useExtEventGroups.js +39 -39
- package/dist/react/hooks/useQuestionForms.js +21 -26
- package/dist/react/hooks/useRunStream.js +10 -2
- package/dist/react/index.d.ts +7 -1
- package/dist/react/index.js +9 -0
- package/dist/react/message-blocks.d.ts +6 -4
- package/dist/react/message-blocks.js +94 -72
- package/dist/react/slots.d.ts +2 -2
- package/dist/react/tool-call-display.js +19 -17
- package/dist/server/durable-runs/recover.js +132 -80
- package/dist/server/run-finalizer/entry.js +25 -15
- package/dist/store/legacy/sqlite/conversations.d.ts +11 -11
- package/dist/store/legacy/sqlite/conversations.js +69 -98
- package/dist/store/legacy/sqlite/messages.d.ts +10 -2
- package/dist/store/legacy/sqlite/messages.js +113 -71
- package/dist/store/runtime/chat-history-store.js +9 -3
- package/dist/store/runtime/durable-run-store.d.ts +2 -2
- package/dist/store/runtime/durable-run-store.js +165 -117
- package/dist/store/runtime/history-tools.js +118 -82
- package/dist/store/runtime/run-ledger.d.ts +4 -1
- package/dist/store/runtime/run-ledger.js +56 -16
- package/dist/store/sql/cursor.js +38 -18
- package/dist/store/sql/store.d.ts +18 -0
- package/dist/store/sql/store.js +46 -15
- 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
|
-
|
|
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
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
package/dist/react/slots.d.ts
CHANGED
|
@@ -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
|
|
183
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
61
|
+
if (block.kind === 'tools')
|
|
62
|
+
turnToolsIndex = out.length;
|
|
61
63
|
out.push(block);
|
|
62
64
|
}
|
|
63
65
|
return out;
|
|
@@ -7,6 +7,33 @@ export { RECOVERY_LIMIT, RECOVERY_WINDOW_MS, RUN_STALENESS_MS, SAVED_WORK_NOTICE
|
|
|
7
7
|
// translateRunFrame saves a run error frame as a status label holding only its message: the
|
|
8
8
|
// daemon's MCP_BRIDGE_UNAVAILABLE code is dropped, so the bridge failure is matched by its text.
|
|
9
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
|
+
}
|
|
10
37
|
/** Retryability is independent of session resumability. Inspect executor diagnostics, never
|
|
11
38
|
* answer text or tool results: those can quote unrelated failures from completed work. Old
|
|
12
39
|
* attempt diagnostics remain visible but cannot decide the fate of their continuation. */
|
|
@@ -14,55 +41,36 @@ function attemptDiagnostics({ run }, _optional = {}) {
|
|
|
14
41
|
const events = run.message.events ?? [];
|
|
15
42
|
// Checkpoints merge adjacent text, so a base's event count is not a durable offset. The
|
|
16
43
|
// coordinator's recovery marker separates attempts and survives that compaction.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
if (event.kind === "status" && event.code === "run_recovering") {
|
|
22
|
-
marker = index;
|
|
23
|
-
break;
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
return events.slice(marker >= 0 ? marker + 1 : run.attemptBase.length).flatMap((event) => {
|
|
27
|
-
if (event.kind === "status")
|
|
28
|
-
return [event.label, event.detail ?? ""];
|
|
29
|
-
if (event.kind !== "raw")
|
|
30
|
-
return [];
|
|
31
|
-
try {
|
|
32
|
-
const result = JSON.parse(event.line);
|
|
33
|
-
// Successful CLI summaries contain usage metadata and answer prose, not failure evidence.
|
|
34
|
-
if (result?.type === "result" && result.is_error === false)
|
|
35
|
-
return [];
|
|
36
|
-
}
|
|
37
|
-
catch {
|
|
38
|
-
// Non-JSON stdout can still contain the executor's failure reason.
|
|
39
|
-
}
|
|
40
|
-
return [event.line];
|
|
41
|
-
}).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");
|
|
42
48
|
}
|
|
43
|
-
|
|
44
|
-
|
|
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 = {}) {
|
|
45
58
|
// MCP_BRIDGE_UNAVAILABLE needs bridge repair; another attempt repeats the same failure.
|
|
46
59
|
// Classify it before interpreting the bridge's stderr as an agent auth/watchdog failure.
|
|
47
60
|
if (MCP_BRIDGE_FAILURE.test(diagnostics))
|
|
48
61
|
return "permanent";
|
|
49
62
|
if (/inactivity[_ -]?(timeout|watchdog)|watchdog.*(kill|stop)/i.test(diagnostics))
|
|
50
63
|
return "inactivity-watchdog";
|
|
51
|
-
|
|
52
|
-
if (service === "AGENT_AUTH_REQUIRED")
|
|
64
|
+
if (classifyAgentServiceFailure({ text: diagnostics }) === "AGENT_AUTH_REQUIRED")
|
|
53
65
|
return "auth-failure";
|
|
54
|
-
|
|
55
|
-
// overload remain retryable; the provider's coarse RATE_LIMITED class also includes billing.
|
|
56
|
-
const requestStatuses = diagnostics.matchAll(/\b(?:http(?:[ /]?\d(?:\.\d)?)?|(?:status|error|response)(?:[ _-]?code)?|code(?=\s*[:=#]))[\s:=#-]*(4\d\d)\b/gi);
|
|
57
|
-
if ([...requestStatuses].some((match) => match[1] !== "408" && match[1] !== "429"))
|
|
58
|
-
return "permanent";
|
|
59
|
-
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))
|
|
60
|
-
return "permanent";
|
|
61
|
-
return "retryable";
|
|
66
|
+
return needsHumanRepair({ diagnostics }) ? "permanent" : "retryable";
|
|
62
67
|
}
|
|
63
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
|
+
}
|
|
64
72
|
function attemptFailureDetail({ run }, _optional = {}) {
|
|
65
|
-
return attemptDiagnostics({ run }
|
|
73
|
+
return lastDiagnosticLine({ diagnostics: attemptDiagnostics({ run }) });
|
|
66
74
|
}
|
|
67
75
|
function exclusion(run) {
|
|
68
76
|
if (run.cancelReason)
|
|
@@ -71,15 +79,48 @@ function exclusion(run) {
|
|
|
71
79
|
return "excluded";
|
|
72
80
|
return null;
|
|
73
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
|
+
}
|
|
74
100
|
function budgetNotice(run, now) {
|
|
75
101
|
if (now - run.lastProgressAt >= RUN_STALENESS_MS)
|
|
76
|
-
return
|
|
102
|
+
return awaitingAnswer({ run }) ? AWAITING_ANSWER_NOTICE : SAVED_WORK_NOTICE;
|
|
77
103
|
if (run.recoveryCount >= RECOVERY_LIMIT)
|
|
78
104
|
return EXHAUSTED_NOTICE;
|
|
79
105
|
if (run.recoveryDeadline !== null && now >= run.recoveryDeadline)
|
|
80
106
|
return EXHAUSTED_NOTICE;
|
|
81
107
|
return null;
|
|
82
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
|
+
}
|
|
83
124
|
/** Recovery coordinator behind explicit host ports. One CAS owns both the attempt and its conversation slot.
|
|
84
125
|
* Each recovery decision logs one `logger.info` line (default console) of run metadata. */
|
|
85
126
|
export function createDurableRecovery(ports, { logger = console } = {}) {
|
|
@@ -98,6 +139,16 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
|
|
|
98
139
|
return false;
|
|
99
140
|
return ports.verifyChildDead(run.child, {}).catch(() => false);
|
|
100
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
|
+
}
|
|
101
152
|
async function continueDead(run) {
|
|
102
153
|
await ports.cancelAttempt(run, {}).catch(() => undefined);
|
|
103
154
|
// A locator captured before a hard cancel can outlive its rollout. Reuse reconstruction,
|
|
@@ -105,10 +156,7 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
|
|
|
105
156
|
const missingResume = run.request.agentId === 'codex' && /no rollout found for thread id/.test(attemptDiagnostics({ run }, {}));
|
|
106
157
|
const native = !missingResume && await nativeAllowed(run);
|
|
107
158
|
const nextRunId = ports.mintRunId();
|
|
108
|
-
const
|
|
109
|
-
if (!saved.some((event) => event.kind === "text") && run.message.content)
|
|
110
|
-
saved.unshift({ kind: "text", text: run.message.content });
|
|
111
|
-
const events = recoveredRunEvents({ saved }, { notice: missingResume ? 'silent' : 'visible' });
|
|
159
|
+
const events = recoveredRunEvents({ saved: savedWork({ run }) }, { notice: missingResume ? 'silent' : 'visible' });
|
|
112
160
|
if (!await ports.store.advance({ run, nextRunId, now: ports.now(), events }, {}))
|
|
113
161
|
return "superseded";
|
|
114
162
|
const next = await ports.store.load({ messageId: run.messageId }, {});
|
|
@@ -117,41 +165,46 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
|
|
|
117
165
|
if (!next || next.runId !== nextRunId)
|
|
118
166
|
return "superseded";
|
|
119
167
|
if (next.cancelReason)
|
|
120
|
-
return finalize(next,
|
|
121
|
-
|
|
122
|
-
await ports.launch({ run: next, request: continuationRequest({ run: { ...run, message: next.message, transcript: next.transcript }, native }, {}) }, {});
|
|
123
|
-
await ports.store.recoveryClock({ runId: next.runId, now: ports.now(), active: false }, {});
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
126
|
-
// A rejected/lost start response is uncertain: the daemon may already be executing. The
|
|
127
|
-
// same idempotent attempt is probed by the attached watcher before another CAS is possible.
|
|
128
|
-
}
|
|
168
|
+
return finalize(next, STOPPED_NOTICE, { canceled: true });
|
|
169
|
+
await launchNext({ run, next, native });
|
|
129
170
|
ports.attach(next, {});
|
|
130
171
|
return "continued";
|
|
131
172
|
}
|
|
132
|
-
async function
|
|
133
|
-
if (exclusion(run))
|
|
134
|
-
return finalize(run, "Stopped. Saved work is above.", { canceled: true });
|
|
173
|
+
async function probeAttempt({ run, trigger, liveRunId }, _optional = {}) {
|
|
135
174
|
// A terminal executor end confirms this attempt is dead even when the daemon retains its
|
|
136
175
|
// replayable record (HTTP 200). Other triggers must obtain fresh liveness evidence.
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
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) };
|
|
143
201
|
// Diagnostics decide only the fate of an attempt that is no longer running. Read before the
|
|
144
202
|
// probe, raw stdout quoting a tool description ("OAuth-authenticated") canceled a live turn
|
|
145
203
|
// as "Not logged in" (luvira, 2026-10-08).
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (failure === "permanent") {
|
|
151
|
-
const bridgeNotice = attemptDiagnostics({ run }, {}).match(MCP_BRIDGE_FAILURE)?.[0];
|
|
152
|
-
return finalize(run, bridgeNotice ?? EXHAUSTED_NOTICE, { detail: bridgeNotice ? undefined : attemptFailureDetail({ run }, {}) });
|
|
153
|
-
}
|
|
154
|
-
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 };
|
|
155
208
|
}
|
|
156
209
|
async function resolveMissing(run, probe, trigger) {
|
|
157
210
|
await ports.store.recoveryClock({ runId: run.runId, now: ports.now(), active: true }, {});
|
|
@@ -160,16 +213,16 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
|
|
|
160
213
|
// a newer attempt committed by another caller while this one was awaiting storage/HTTP.
|
|
161
214
|
if (timed.runId !== run.runId)
|
|
162
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);
|
|
163
220
|
const notice = budgetNotice(timed, ports.now());
|
|
164
|
-
if (notice)
|
|
165
|
-
|
|
166
|
-
? attemptFailureDetail({ run: timed }, {}) ?? (timed.recoveryCount >= RECOVERY_LIMIT ? `Tried ${RECOVERY_LIMIT} times` : "Recovery window ended")
|
|
167
|
-
: undefined;
|
|
168
|
-
return finalize(run, notice, { detail });
|
|
169
|
-
}
|
|
221
|
+
if (notice)
|
|
222
|
+
return finalize(run, notice, { detail: notice === EXHAUSTED_NOTICE ? exhaustedDetail({ run: timed }) : undefined });
|
|
170
223
|
// A queued attempt may be between durable acceptance and the daemon's start transaction.
|
|
171
224
|
// Give that idempotent launch time to appear before a 404 can advance its generation.
|
|
172
|
-
if (
|
|
225
|
+
if (continuesNow({ run, probe, trigger, now: ports.now() }))
|
|
173
226
|
return continueDead(timed);
|
|
174
227
|
ports.attach(run, {});
|
|
175
228
|
return "waiting";
|
|
@@ -183,12 +236,11 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
|
|
|
183
236
|
return "terminal";
|
|
184
237
|
if (expectedRunId && expectedRunId !== run.runId)
|
|
185
238
|
return "superseded";
|
|
186
|
-
const
|
|
187
|
-
const decision = await decide(run, trigger, options.liveRunId, seen);
|
|
239
|
+
const { decision, probe, failure } = await decide(run, trigger, options.liveRunId);
|
|
188
240
|
// The watcher re-confirms its own live run every resolution window; log only real recovery
|
|
189
241
|
// decisions. Metadata only: diagnostics, answers and cancel reasons can hold secrets.
|
|
190
242
|
if (options.liveRunId !== run.runId)
|
|
191
|
-
logger.info("[durable-recovery] " + JSON.stringify({ runId: run.runId, trigger, probe
|
|
243
|
+
logger.info("[durable-recovery] " + JSON.stringify({ runId: run.runId, trigger, probe, decision, failureClass: failure }));
|
|
192
244
|
return decision;
|
|
193
245
|
},
|
|
194
246
|
};
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { isTerminalRunStatus } from '../../core/messages.js';
|
|
2
2
|
import { readSseFrames, runContentFromEvents, runEventsForSave, runInterruptedNotice, translateRunFrame } from '../../core/run-events/entry.js';
|
|
3
|
+
/** An error frame keeps a later successful end from settling as a success. */
|
|
4
|
+
function endStatus({ failed, terminal }, _optional = {}) {
|
|
5
|
+
return failed && terminal === "succeeded" ? "failed" : terminal;
|
|
6
|
+
}
|
|
3
7
|
/** Follow replayable run streams and save through atomic host ledger operations.
|
|
4
8
|
* The host retains authorization, owner scope, kernel transactions, boot reconciliation, HTTP
|
|
5
9
|
* credentials and excluded run-ID prefixes. Replay starts at zero; error-before-success and
|
|
@@ -22,11 +26,14 @@ export function createAssistantRunFinalizer(args, options = {}) {
|
|
|
22
26
|
catch { /* The host owns reporting failures. */ }
|
|
23
27
|
}
|
|
24
28
|
const active = new Map();
|
|
25
|
-
|
|
26
|
-
watch.terminal = true;
|
|
29
|
+
function clearTrailing(watch) {
|
|
27
30
|
if (watch.trailing)
|
|
28
31
|
scheduler.cancel({ handle: watch.trailing.handle });
|
|
29
32
|
watch.trailing = undefined;
|
|
33
|
+
}
|
|
34
|
+
async function settle(watch, status, events) {
|
|
35
|
+
watch.terminal = true;
|
|
36
|
+
clearTrailing(watch);
|
|
30
37
|
await args.ledger.settle({
|
|
31
38
|
conversationId: watch.conversationId,
|
|
32
39
|
messageId: watch.messageId,
|
|
@@ -57,9 +64,7 @@ export function createAssistantRunFinalizer(args, options = {}) {
|
|
|
57
64
|
}
|
|
58
65
|
return;
|
|
59
66
|
}
|
|
60
|
-
|
|
61
|
-
scheduler.cancel({ handle: watch.trailing.handle });
|
|
62
|
-
watch.trailing = undefined;
|
|
67
|
+
clearTrailing(watch);
|
|
63
68
|
watch.checkpointedEvents = watch.events.length;
|
|
64
69
|
watch.checkpointedAt = now();
|
|
65
70
|
watch.checkpointPending = true;
|
|
@@ -77,29 +82,36 @@ export function createAssistantRunFinalizer(args, options = {}) {
|
|
|
77
82
|
void checkpoint(watch);
|
|
78
83
|
});
|
|
79
84
|
}
|
|
80
|
-
|
|
85
|
+
/** The connection's event body, or why it cannot be followed. Non-success bodies are cancelled. */
|
|
86
|
+
async function openStream(watch) {
|
|
81
87
|
const response = await daemon.openEvents({ runId: watch.runId, principalId: watch.principalId });
|
|
82
|
-
if (response.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
88
|
+
if (response.ok && response.body)
|
|
89
|
+
return { kind: "open", body: response.body };
|
|
90
|
+
await response.body?.cancel().catch(() => undefined);
|
|
91
|
+
return response.status === 404 ? { kind: "gone" } : { kind: "dropped" };
|
|
92
|
+
}
|
|
93
|
+
async function consumeFrames(watch, body) {
|
|
86
94
|
// Each connection replays the complete history, rather than appending duplicate deltas.
|
|
87
95
|
watch.events = [];
|
|
88
96
|
watch.failed = false;
|
|
89
97
|
const carry = {};
|
|
90
|
-
for await (const frame of readSseFrames({ body
|
|
98
|
+
for await (const frame of readSseFrames({ body })) {
|
|
91
99
|
const outcome = translateRunFrame({ kind: frame.event, raw: frame.data, notices: args.notices }, { carry });
|
|
92
100
|
watch.events.push(...outcome.events);
|
|
93
101
|
if (outcome.error)
|
|
94
102
|
watch.failed = true;
|
|
95
103
|
if (outcome.terminal) {
|
|
96
104
|
watch.terminal = true;
|
|
97
|
-
return { kind: "ended", status: watch.failed
|
|
105
|
+
return { kind: "ended", status: endStatus({ failed: watch.failed, terminal: outcome.terminal }) };
|
|
98
106
|
}
|
|
99
107
|
void checkpoint(watch);
|
|
100
108
|
}
|
|
101
109
|
return { kind: "dropped" };
|
|
102
110
|
}
|
|
111
|
+
async function readStream(watch) {
|
|
112
|
+
const opened = await openStream(watch);
|
|
113
|
+
return opened.kind === "open" ? consumeFrames(watch, opened.body) : opened;
|
|
114
|
+
}
|
|
103
115
|
async function follow(watch) {
|
|
104
116
|
for (let attempt = 0; attempt <= maxReconnects; attempt += 1) {
|
|
105
117
|
const result = await readStream(watch).catch(() => ({ kind: "dropped" }));
|
|
@@ -140,9 +152,7 @@ export function createAssistantRunFinalizer(args, options = {}) {
|
|
|
140
152
|
})
|
|
141
153
|
.finally(() => {
|
|
142
154
|
watch.terminal = true;
|
|
143
|
-
|
|
144
|
-
scheduler.cancel({ handle: watch.trailing.handle });
|
|
145
|
-
watch.trailing = undefined;
|
|
155
|
+
clearTrailing(watch);
|
|
146
156
|
active.delete(runId);
|
|
147
157
|
});
|
|
148
158
|
active.set(runId, { watch, done });
|