@jini-ai/chat 0.6.19 → 0.6.22
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 +11 -0
- package/dist/core/accepted-input.d.ts +10 -2
- package/dist/core/accepted-input.js +87 -33
- package/dist/core/assistant-client/durable-run-subscription.js +6 -0
- package/dist/core/assistant-client/entry.d.ts +1 -1
- package/dist/core/assistant-client/entry.js +1 -1
- package/dist/core/assistant-client/messages.d.ts +21 -0
- package/dist/core/assistant-client/messages.js +31 -0
- package/dist/core/checkpoint-interval.d.ts +25 -0
- package/dist/core/checkpoint-interval.js +38 -0
- package/dist/core/durable-runs/ledger-contracts.d.ts +27 -1
- package/dist/core/durable-runs/ports.d.ts +25 -1
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/tool-events.d.ts +5 -2
- package/dist/core/tool-events.js +1 -16
- package/dist/react/components/Composer.d.ts +3 -3
- package/dist/react/components/Composer.js +3 -3
- package/dist/react/components/MessageList.js +8 -3
- package/dist/react/components/MessageRow.d.ts +2 -0
- package/dist/react/components/MessageRow.js +8 -5
- package/dist/react/components/MidRunUserMessage.js +1 -1
- package/dist/react/ext-event-renderer-registry.d.ts +8 -0
- package/dist/react/ext-event-renderer-registry.js +13 -2
- package/dist/react/features/chat-pane/components/ChatPane.js +1 -1
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +5 -4
- package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +3 -9
- package/dist/react/hooks/useConversation.js +13 -2
- package/dist/server/durable-runs/recover.js +3 -3
- package/dist/store/ports.d.ts +17 -3
- package/dist/store/runtime/chat-history-store.js +6 -0
- package/dist/store/runtime/checkpoint-delta.d.ts +35 -0
- package/dist/store/runtime/checkpoint-delta.js +75 -0
- package/dist/store/runtime/durable-run-store.js +92 -25
- package/dist/store/runtime/events-json.d.ts +17 -0
- package/dist/store/runtime/events-json.js +19 -0
- package/dist/store/runtime/run-guarded-chat-store.js +8 -3
- package/dist/store/runtime/run-ledger.d.ts +2 -2
- package/dist/store/runtime/run-ledger.js +120 -45
- package/dist/store/sql/read-version.d.ts +15 -0
- package/dist/store/sql/read-version.js +21 -0
- package/dist/store/sql/store.d.ts +9 -0
- package/dist/store/sql/store.js +89 -4
- package/dist/store/sqlite/schema.d.ts +2 -2
- package/dist/store/sqlite/schema.js +4 -0
- package/package.json +9 -9
|
@@ -49,7 +49,7 @@ function MidRunInput({ input, queued, transcriptRow }) {
|
|
|
49
49
|
const t = useT();
|
|
50
50
|
const attachments = input.attachments ?? [];
|
|
51
51
|
const gallery = useAttachmentGallery({ attachments });
|
|
52
|
-
return (_jsxs("div", { className: `jini-message jini-message-user${transcriptRow ? '' : ' jini-message-user--mid-run'}`, "data-agent-role": "region", "data-agent-label": "A message from the user, sent while the agent was working", children: [queued && _jsx("div", { className: "jini-message-user__queued", role: "status", children: t('Queued — read after the current step') }), input.folders?.length ? _jsx("div", { className: "jini-message-attachments", children: _jsx(FolderChips, { folders: input.folders }) }) : null, _jsx("div", { className: "jini-message-content", children: input.text }), attachments.length > 0 && _jsx("div", { className: "jini-message-attachments", children: attachments.map(attachment => _jsx(SentAttachmentButton, { attachment: attachment, onOpen: gallery.open }, attachment.path)) }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: input.text, label: t('Copy message') }) }), gallery.opened !== null && _jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation })] }));
|
|
52
|
+
return (_jsxs("div", { className: `jini-message jini-message-user${transcriptRow ? '' : ' jini-message-user--mid-run'}`, "data-agent-role": "region", "data-agent-label": "A message from the user, sent while the agent was working", children: [queued && _jsx("div", { className: "jini-chat-pane__status jini-message-user__queued", role: "status", children: t('Queued — read after the current step') }), input.folders?.length ? _jsx("div", { className: "jini-message-attachments", children: _jsx(FolderChips, { folders: input.folders }) }) : null, _jsx("div", { className: "jini-message-content", children: input.text }), attachments.length > 0 && _jsx("div", { className: "jini-message-attachments", children: attachments.map(attachment => _jsx(SentAttachmentButton, { attachment: attachment, onOpen: gallery.open }, attachment.path)) }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: input.text, label: t('Copy message') }) }), gallery.opened !== null && _jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation })] }));
|
|
53
53
|
}
|
|
54
54
|
/**
|
|
55
55
|
* Registers {@link MidRunUserMessage} against the ext-event registry.
|
|
@@ -97,6 +97,10 @@ export type ExtEventSlotKey = (required: {
|
|
|
97
97
|
export interface ExtEventRendererOptions {
|
|
98
98
|
/** See {@link ExtEventSlotKey}. Omit for one shared slot per `name`. */
|
|
99
99
|
slotKey?: ExtEventSlotKey;
|
|
100
|
+
/** True only when the renderer will show the terminal explanation for these validated events. */
|
|
101
|
+
terminalNotice?: (required: {
|
|
102
|
+
events: readonly unknown[];
|
|
103
|
+
}) => boolean;
|
|
100
104
|
}
|
|
101
105
|
/** Owned renderer and slot-key state for one chat tree. */
|
|
102
106
|
export interface ExtEventRendererRegistry {
|
|
@@ -111,6 +115,10 @@ export interface ExtEventRendererRegistry {
|
|
|
111
115
|
name: string;
|
|
112
116
|
data: unknown;
|
|
113
117
|
}): string;
|
|
118
|
+
hasTerminalNotice(required: {
|
|
119
|
+
name: string;
|
|
120
|
+
events: readonly unknown[];
|
|
121
|
+
}): boolean;
|
|
114
122
|
clear(): void;
|
|
115
123
|
}
|
|
116
124
|
/** Create independent extension renderer state, including each protocol's slot grouping.
|
|
@@ -10,7 +10,7 @@ export function createExtEventRendererRegistry(_required = {}) {
|
|
|
10
10
|
* can dispose cleanly. Re-registering the same name overwrites — last writer wins.
|
|
11
11
|
*/
|
|
12
12
|
function register({ name, renderer }, options = {}) {
|
|
13
|
-
const entry = { renderer, slotKey: options.slotKey };
|
|
13
|
+
const entry = { renderer, slotKey: options.slotKey, terminalNotice: options.terminalNotice };
|
|
14
14
|
renderers.set(name, entry);
|
|
15
15
|
return () => {
|
|
16
16
|
if (renderers.get(name) === entry)
|
|
@@ -20,6 +20,17 @@ export function createExtEventRendererRegistry(_required = {}) {
|
|
|
20
20
|
function get({ name }) {
|
|
21
21
|
return renderers.get(name)?.renderer;
|
|
22
22
|
}
|
|
23
|
+
/** Let a registered, validated explanation own the terminal surface; failures retain the fallback.
|
|
24
|
+
* @complexity O(1) registry lookup plus the host's validation; no effects.
|
|
25
|
+
*/
|
|
26
|
+
function hasTerminalNotice({ name, events }) {
|
|
27
|
+
try {
|
|
28
|
+
return renderers.get(name)?.terminalNotice?.({ events }) === true;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
23
34
|
/**
|
|
24
35
|
* The render slot an ext event belongs to: its `name`, or `name:key` when the name's renderer was
|
|
25
36
|
* registered with a {@link ExtEventSlotKey} that recognizes this event. A throwing key function
|
|
@@ -42,7 +53,7 @@ export function createExtEventRendererRegistry(_required = {}) {
|
|
|
42
53
|
}
|
|
43
54
|
/** Visible mainly for tests. */
|
|
44
55
|
function clear() { renderers.clear(); }
|
|
45
|
-
return { register, get, slot, clear };
|
|
56
|
+
return { register, get, slot, hasTerminalNotice, clear };
|
|
46
57
|
}
|
|
47
58
|
/** Compatibility default for hosts that have not supplied owned state. */
|
|
48
59
|
export const defaultExtEventRendererRegistry = createExtEventRendererRegistry({});
|
|
@@ -225,7 +225,7 @@ function ChatPaneComposerArea({ fileDrop, uploadAttachments, pane, disabled, una
|
|
|
225
225
|
// rather than refusing it, so the operator can keep writing while the agent thinks.
|
|
226
226
|
disabled: disabled || unavailable,
|
|
227
227
|
// The one blocker `send()` handles itself. Every other refusal still greys out the button.
|
|
228
|
-
sendDisabled: !pane.canSend && pane.sendBlocker !== 'streaming', ...definedProps({ source: { sendDisabledReason, sendDisabledReasonId: sendDisabledReason ? sendDisabledReasonId : undefined } }), running: pane.conversation.isStreaming, onCancel: pane.conversation.cancel,
|
|
228
|
+
sendDisabled: !pane.canSend && pane.sendBlocker !== 'streaming', ...definedProps({ source: { sendDisabledReason, sendDisabledReasonId: sendDisabledReason ? sendDisabledReasonId : undefined } }), running: pane.conversation.isStreaming, onCancel: pane.conversation.cancel, ...definedProps({ source: { placeholder } }), slots: { ...slots, footerAccessories: _jsxs(_Fragment, { children: [sendDisabledReason ? _jsx("small", { id: sendDisabledReasonId, className: "jini-composer-send-blocker", children: sendDisabledReason }) : null, slots.footerAccessories] }) }, ...resolveComposerAttachmentPicker(uploadAttachments, pane, attachmentAccept), ...resolveComposerWorkingDirectory(workingDirectoryAccess, workingDirectoryControlPlacement, pane) }), _jsx(ChatPaneWorkingDirectoryBlock, { workingDirectoryAccess: workingDirectoryAccess, workingDirectoryControlPlacement: workingDirectoryControlPlacement, pane: pane, t: t })] }));
|
|
229
229
|
}
|
|
230
230
|
const EMPTY_SUGGESTIONS = [];
|
|
231
231
|
const CHAT_PANE_DEFAULTS = {
|
|
@@ -115,10 +115,11 @@ export interface UseChatPaneResult extends UseChatPaneWorkingDirectoryResult {
|
|
|
115
115
|
*/
|
|
116
116
|
sendAsNewMessage: () => Promise<void>;
|
|
117
117
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* queue-then-cancel rather than cancel-then-send
|
|
121
|
-
* that never
|
|
118
|
+
* @deprecated Use send and let Composer apply sendDisabled to keyboard shortcuts.
|
|
119
|
+
* Legacy name for the modifier-key send shortcut; uses {@link send} and keeps the run going.
|
|
120
|
+
* Previously queue-then-cancel rather than cancel-then-send kept both paths on one flush so a
|
|
121
|
+
* cancel that never landed could not strand the prompt. Sharing send now also preserves that
|
|
122
|
+
* queue ownership, while a capable run reads the input after its current step.
|
|
122
123
|
*/
|
|
123
124
|
interruptSend: () => void;
|
|
124
125
|
reset: () => void;
|
|
@@ -756,15 +756,9 @@ export function useChatPane(options) {
|
|
|
756
756
|
guard.clearNotice();
|
|
757
757
|
await sendOrdinaryTurn(prompt);
|
|
758
758
|
}, [composer, guard.clearNotice, sendOrdinaryTurn]);
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
return;
|
|
763
|
-
guard.clearNotice();
|
|
764
|
-
enqueueTurn({ turn: guard.redact({ text: prompt }, {}) });
|
|
765
|
-
composer.setDraft('');
|
|
766
|
-
conversation.cancel();
|
|
767
|
-
}, [composer, conversation, enqueueTurn, guard.clearNotice, guard.redact]);
|
|
759
|
+
// A send shortcut must honor the same mid-run delivery contract as the Send button.
|
|
760
|
+
// Unsupported agents wait behind the current reply; only the explicit Stop action cancels it.
|
|
761
|
+
const interruptSend = useCallback(() => { void send(); }, [send]);
|
|
768
762
|
/** Abort only uploads owned by the removed inputs; late completions must not refill the composer. */
|
|
769
763
|
const discardQueuedUploads = useCallback(({ id }, _options = {}) => {
|
|
770
764
|
for (const [controller, owner] of queuedUploadOwnersRef.current) {
|
|
@@ -37,6 +37,15 @@ function pendingUserMessage({ content, sendOptions, createMessageId }, options =
|
|
|
37
37
|
...(sendOptions.attachments ? { attachments: sendOptions.attachments } : {}),
|
|
38
38
|
} }, options).message;
|
|
39
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
|
+
}
|
|
40
49
|
/** The queued assistant bubble a send opens; the run's events fill it in. */
|
|
41
50
|
function pendingAssistantMessage({ id }, { agentId } = {}) {
|
|
42
51
|
return {
|
|
@@ -164,9 +173,11 @@ export function useConversation(options) {
|
|
|
164
173
|
const assistantId = activeAssistantIdRef.current;
|
|
165
174
|
if (!assistantId)
|
|
166
175
|
return;
|
|
167
|
-
setMessagesState((prev) => prev.map((m) => {
|
|
176
|
+
setMessagesState((prev) => prev.map((m, index) => {
|
|
168
177
|
if (m.id !== assistantId)
|
|
169
|
-
return
|
|
178
|
+
return acceptPendingUserMessage({
|
|
179
|
+
message: m, reply: prev[index + 1], assistantId, runId: run.runId,
|
|
180
|
+
});
|
|
170
181
|
// A plain if/else chain (not a nested ternary) — deliberately, so
|
|
171
182
|
// each of the four mapped statuses is its own independently
|
|
172
183
|
// trackable branch under coverage instrumentation. The fallback
|
|
@@ -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")
|
package/dist/store/ports.d.ts
CHANGED
|
@@ -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
|
|
92
|
-
|
|
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?: {
|
|
@@ -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
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { dedupeToolUsesById } from "../../core/tool-events.js";
|
|
2
|
+
/** Compare unchanged prefix values; compacting text creates fresh objects, while large tool
|
|
3
|
+
* payloads normally retain their reference. Never parse the saved SQL column here. */
|
|
4
|
+
function sameEvent(left, right) {
|
|
5
|
+
return left === right || JSON.stringify(left) === JSON.stringify(right);
|
|
6
|
+
}
|
|
7
|
+
/** A delta may append events or replace the final coalesced text event. Every earlier event
|
|
8
|
+
* must match; replay reordering, thinking replacement and tool identity deduplication fall back.
|
|
9
|
+
* @complexity O(prefix bytes + new suffix bytes) time; O(new suffix bytes) temporary space. */
|
|
10
|
+
export function checkpointDelta({ previous, progress }, _options = {}) {
|
|
11
|
+
if (progress.content.length < previous.content.length)
|
|
12
|
+
return undefined;
|
|
13
|
+
const incoming = progress.events;
|
|
14
|
+
const saved = previous.events;
|
|
15
|
+
if (incoming.length < saved.length)
|
|
16
|
+
return undefined;
|
|
17
|
+
if (dedupeToolUsesById({ events: incoming }).length !== incoming.length)
|
|
18
|
+
return undefined;
|
|
19
|
+
const prefix = saved.slice(0, -1);
|
|
20
|
+
if (!prefix.every((event, index) => sameEvent(event, incoming[index])))
|
|
21
|
+
return undefined;
|
|
22
|
+
const last = saved.at(-1);
|
|
23
|
+
const next = incoming[saved.length - 1];
|
|
24
|
+
const replacing = last !== undefined && !sameEvent(last, next);
|
|
25
|
+
if (replacing && !extendsText({ last, next }))
|
|
26
|
+
return undefined;
|
|
27
|
+
return { ...deltaSuffix({ previous, incoming, replacing }),
|
|
28
|
+
grown: progress.content.length > previous.content.length || hasNewToolProgress({ saved, incoming }) };
|
|
29
|
+
}
|
|
30
|
+
/** SQL counts characters in the removed tail, so non-BMP text never uses JS UTF-16 offsets. */
|
|
31
|
+
function deltaSuffix({ previous, incoming, replacing }, _options = {}) {
|
|
32
|
+
const from = previous.events.length - (replacing ? 1 : 0);
|
|
33
|
+
const suffixEvents = incoming.slice(from);
|
|
34
|
+
const separator = !replacing && from > 0 && suffixEvents.length > 0 ? ',' : '';
|
|
35
|
+
return { removeCharacters: replacing ? previous.tailLength : 1,
|
|
36
|
+
suffix: separator + JSON.stringify(suffixEvents).slice(1) };
|
|
37
|
+
}
|
|
38
|
+
/** Extending text must retain every other field (including any future event metadata). */
|
|
39
|
+
function extendsText({ last, next }, _options = {}) {
|
|
40
|
+
if (last.kind !== 'text' || next?.kind !== 'text')
|
|
41
|
+
return false;
|
|
42
|
+
return next.text.startsWith(last.text) && sameEvent({ ...last, text: next.text }, next);
|
|
43
|
+
}
|
|
44
|
+
/** Same tool identity rule as the full checkpoint's progress check. O(saved + incoming events). */
|
|
45
|
+
export function hasNewToolProgress({ saved, incoming }, _options = {}) {
|
|
46
|
+
const keys = new Set(saved.map(event => toolKey({ event })));
|
|
47
|
+
return incoming.some(event => { const key = toolKey({ event }); return key !== '' && !keys.has(key); });
|
|
48
|
+
}
|
|
49
|
+
/** Durable tool identity shared by the full merge and delta progress check. */
|
|
50
|
+
export function toolKey({ event }, _options = {}) {
|
|
51
|
+
if (event.kind === 'tool_use')
|
|
52
|
+
return 'use:' + event.id;
|
|
53
|
+
if (event.kind === 'tool_result')
|
|
54
|
+
return 'result:' + event.toolUseId;
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
/** Build a receipt only for an exact incoming projection; callers own its watch lifetime.
|
|
58
|
+
* SQL supplies content/event lengths; the removed tail counts Unicode code points. Retain only
|
|
59
|
+
* a bounded suffix for CAS, so a multi-MB tool result never becomes a guard parameter twice.
|
|
60
|
+
* @complexity O(last event bytes) serialization/counting; O(last event bytes) transient space,
|
|
61
|
+
* with at most 256 UTF-16 units retained beyond the existing projection references.
|
|
62
|
+
*/
|
|
63
|
+
export function checkpointState({ progress, length, contentLength, version }, _options = {}) {
|
|
64
|
+
const last = progress.events.at(-1);
|
|
65
|
+
const fullTail = last === undefined ? '[]' : JSON.stringify(last) + ']';
|
|
66
|
+
// Count SQL characters without allocating an array of code points for a multi-MB tool result.
|
|
67
|
+
let tailLength = 0;
|
|
68
|
+
for (const _character of fullTail)
|
|
69
|
+
tailLength++;
|
|
70
|
+
let tail = fullTail.slice(-256);
|
|
71
|
+
if (/^[\uDC00-\uDFFF]/u.test(tail))
|
|
72
|
+
tail = tail.slice(1);
|
|
73
|
+
return { content: progress.content, events: progress.events, version, length, contentLength, tailLength, tail };
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=checkpoint-delta.js.map
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sqliteReadVersion } from '../sql/read-version.js';
|
|
1
2
|
import { acceptedInputEvent, acceptedInputUserEvent } from "../../core/accepted-input.js";
|
|
2
3
|
import { redactUserMessage } from "../../core/user-text-redaction.js";
|
|
3
4
|
import { recoveredRunEvents } from "../../core/index.js";
|
|
@@ -8,7 +9,8 @@ import { RECOVERY_WINDOW_MS } from "../../core/durable-runs/policies.js";
|
|
|
8
9
|
import { createChatHistoryStore } from "./chat-history-store.js";
|
|
9
10
|
import { runLockKey } from "../../core/durable-runs/run-lock.js";
|
|
10
11
|
import { runContentFromEvents } from "../../core/run-events/entry.js";
|
|
11
|
-
import {
|
|
12
|
+
import { appendedEventsJson } from "./events-json.js";
|
|
13
|
+
import { toMessage, validatedEventsJson } from "../sql/store.js";
|
|
12
14
|
export const RUN_SLOT_BUSY = "Another answer in this chat is still running. This turn is queued in your browser.";
|
|
13
15
|
export class RunSlotBusyError extends Error {
|
|
14
16
|
constructor() { super(RUN_SLOT_BUSY); }
|
|
@@ -150,15 +152,6 @@ function continuationEvents({ current, incoming }, _optional = {}) {
|
|
|
150
152
|
}
|
|
151
153
|
return runContentFromEvents({ events: incoming }).length >= current.message.content.length ? incoming : saved;
|
|
152
154
|
}
|
|
153
|
-
/** `events_json` with one more event, built in SQL: no dialect parses or re-serializes the saved
|
|
154
|
-
* array in JS. Writers store `JSON.stringify` output (no whitespace), so a non-empty array is
|
|
155
|
-
* `[...]` and gains `,event`; `[]`, NULL and anything not array-shaped (a truncated legacy value,
|
|
156
|
-
* which the old JS read also treated as no events) become `[event]`. Plain `substr`/`length`/`||`
|
|
157
|
-
* run unchanged on SQLite and PostgreSQL. */
|
|
158
|
-
function appendedEventsJson({ event }, _optional = {}) {
|
|
159
|
-
const item = sql `cast(${JSON.stringify(event)} as text)`;
|
|
160
|
-
return sql `case when events_json like '[_%]' then substr(events_json, 1, length(events_json) - 1) || ',' || ${item} || ']' else '[' || ${item} || ']' end`;
|
|
161
|
-
}
|
|
162
155
|
function sameOwner({ run, required }, _optional = {}) {
|
|
163
156
|
return run.principalId === required.principalId && run.conversationId === required.conversationId;
|
|
164
157
|
}
|
|
@@ -176,10 +169,28 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
176
169
|
.select(eventsColumn.as("events_json"))
|
|
177
170
|
.where(match.column, "=", match.value).where("role", "=", "assistant").executeTakeFirst());
|
|
178
171
|
}
|
|
179
|
-
/**
|
|
180
|
-
async function conversationTranscript({ row },
|
|
172
|
+
/** Conversation projection: callers can omit assistant event payloads; legacy heads need only content. */
|
|
173
|
+
async function conversationTranscript({ row }, { includeEvents = true } = {}) {
|
|
181
174
|
const history = createChatHistoryStore({ kernel, isDaemonRunId, scope: { scopeId: row.scope_id, ownerKind: row.owner_kind, ownerId: row.owner_id } }, {});
|
|
182
|
-
return history.messages({ conversationId: row.conversation_id });
|
|
175
|
+
return history.messages({ conversationId: row.conversation_id }, { includeEvents });
|
|
176
|
+
}
|
|
177
|
+
/** Legacy accepted requests and recovery reconstruction consume prior text only. */
|
|
178
|
+
async function readTranscript({ row, metadata }, options = {}) {
|
|
179
|
+
if (!options.transcript && metadata)
|
|
180
|
+
return [];
|
|
181
|
+
const includeEvents = transcriptEventProjection(options);
|
|
182
|
+
return conversationTranscript({ row }, { includeEvents });
|
|
183
|
+
}
|
|
184
|
+
/** Accepted user receipts split queued inputs and hold folder references. Keep that small
|
|
185
|
+
* public metadata even when recovery omits prior assistant event payloads. */
|
|
186
|
+
function transcriptEventProjection(options) {
|
|
187
|
+
if (options.includeTranscriptEvents === false && options.transcript)
|
|
188
|
+
return 'user';
|
|
189
|
+
return options.includeTranscriptEvents ?? options.transcript === true;
|
|
190
|
+
}
|
|
191
|
+
function currentMessage({ row, transcript }, options = {}) {
|
|
192
|
+
return options.transcript && options.includeTranscriptEvents !== false
|
|
193
|
+
? transcript.find(item => item.id === row.id) : toMessage(row);
|
|
183
194
|
}
|
|
184
195
|
/** One run's head, plus its transcript when that was read (`[]` otherwise). */
|
|
185
196
|
async function readRun({ match }, options = {}) {
|
|
@@ -189,11 +200,12 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
189
200
|
const messageId = row.id;
|
|
190
201
|
const metadata = await kernel.run((db) => db.selectFrom("assistant_run_attempts").selectAll().where("message_id", "=", messageId).executeTakeFirst());
|
|
191
202
|
// A legacy row's accepted request is derived from its transcript, so only it reads one here.
|
|
192
|
-
const transcript =
|
|
193
|
-
const message =
|
|
203
|
+
const transcript = await readTranscript({ row, metadata }, options);
|
|
204
|
+
const message = currentMessage({ row, transcript }, options);
|
|
194
205
|
if (!message)
|
|
195
206
|
return null; // Deletion may have cascaded between the two reads.
|
|
196
|
-
return { head: runHead({ row, runId: row.run_id, metadata, message, transcript }, { isDaemonRunId }),
|
|
207
|
+
return { head: runHead({ row, runId: row.run_id, metadata, message, transcript }, { isDaemonRunId }),
|
|
208
|
+
transcript: transcript.map(item => item.id === messageId ? message : item) };
|
|
197
209
|
}
|
|
198
210
|
/** Locked read of one message; `transcript` decides whether it is a whole run or only its head. */
|
|
199
211
|
async function lockedRead({ messageId, conversationId }, options = {}) {
|
|
@@ -204,15 +216,70 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
204
216
|
return readRun({ match: { column: "ai_chat_messages.id", value: messageId } }, options);
|
|
205
217
|
});
|
|
206
218
|
}
|
|
207
|
-
async function load({ messageId },
|
|
219
|
+
async function load({ messageId }, { includeTranscriptEvents = true } = {}) {
|
|
208
220
|
const binding = await kernel.run((db) => db.selectFrom("ai_chat_messages").select("conversation_id")
|
|
209
221
|
.where("id", "=", messageId).executeTakeFirst());
|
|
210
222
|
if (!binding)
|
|
211
223
|
return null;
|
|
212
|
-
//
|
|
213
|
-
|
|
224
|
+
// Full reads take the message from the transcript. Lean reads select its own events once,
|
|
225
|
+
// omitting prior assistant payloads while retaining accepted user receipts.
|
|
226
|
+
const read = await lockedRead({ messageId, conversationId: binding.conversation_id }, { transcript: true, includeTranscriptEvents, events: !includeTranscriptEvents });
|
|
214
227
|
return read && { ...read.head, transcript: read.transcript };
|
|
215
228
|
}
|
|
229
|
+
/** Single-message read; message identity survives continuation while run_id changes.
|
|
230
|
+
* Legacy request reconstruction needs only transcript content, never its old event columns. */
|
|
231
|
+
async function head({ messageId }, { events = true } = {}) {
|
|
232
|
+
const binding = await kernel.run((db) => db.selectFrom("ai_chat_messages").select("conversation_id")
|
|
233
|
+
.where("id", "=", messageId).executeTakeFirst());
|
|
234
|
+
if (!binding)
|
|
235
|
+
return null;
|
|
236
|
+
return (await lockedRead({ messageId, conversationId: binding.conversation_id }, { events }))?.head ?? null;
|
|
237
|
+
}
|
|
238
|
+
/** Read only one owned projection, without a run lock or accepted-request reconstruction.
|
|
239
|
+
* Native database versions follow checkpoint growth, replacements, attempts and settlement. SQL omits
|
|
240
|
+
* content/events on a hit; only changed rows cross into JS as raw JSON, never parsed events.
|
|
241
|
+
* @complexity One indexed row read; O(1) unchanged payload and JS space; changed payload is O(current message bytes).
|
|
242
|
+
*/
|
|
243
|
+
async function poll({ messageId, principalId, workspaceId }, { cursor } = {}) {
|
|
244
|
+
// SQLite has no per-row version: combine this connection's writes with the external-commit
|
|
245
|
+
// generation. Unrelated writes may invalidate the cursor, but equal-length rewrites cannot
|
|
246
|
+
// hide. PostgreSQL supplies the row's own MVCC version. Neither reads the event column.
|
|
247
|
+
const version = kernel.dialect === 'sqlite'
|
|
248
|
+
? sqliteReadVersion({ kernel })
|
|
249
|
+
: sql `cast(ai_chat_messages.xmin as text)`;
|
|
250
|
+
const changed = sql `${version} <> ${cursor ?? ''}`;
|
|
251
|
+
// Run writers persist compact JSON arrays on both engines. SQLite also tolerates malformed
|
|
252
|
+
// legacy rows through its native validator; PG14 has no safe try-cast (do not parse in JS).
|
|
253
|
+
const valid = kernel.dialect === 'sqlite' ? sql `json_valid(events_json)` : sql `1`;
|
|
254
|
+
const row = await kernel.run(db => db.selectFrom('ai_chat_messages')
|
|
255
|
+
.innerJoin('ai_chats', 'ai_chats.id', 'ai_chat_messages.conversation_id')
|
|
256
|
+
.select(['ai_chat_messages.id', 'conversation_id', 'role'])
|
|
257
|
+
.select([
|
|
258
|
+
sql `case when ${changed} then run_id else null end`.as('run_id'),
|
|
259
|
+
sql `case when ${changed} then run_status else null end`.as('run_status'),
|
|
260
|
+
sql `case when ${changed} then agent_id else null end`.as('agent_id'),
|
|
261
|
+
sql `case when ${changed} then agent_name else null end`.as('agent_name'),
|
|
262
|
+
sql `case when ${changed} then attachments_json else null end`.as('attachments_json'),
|
|
263
|
+
sql `case when ${changed} then ai_chat_messages.created_at else 0 end`.as('created_at'),
|
|
264
|
+
sql `case when ${changed} then started_at else null end`.as('started_at'),
|
|
265
|
+
sql `case when ${changed} then ended_at else null end`.as('ended_at')
|
|
266
|
+
])
|
|
267
|
+
.select([version.as('cursor'),
|
|
268
|
+
sql `case when ${changed} then content else '' end`.as('content'),
|
|
269
|
+
sql `case when ${changed} then events_json else null end`.as('events_json'),
|
|
270
|
+
sql `case when ${changed} then ${valid} else null end`.as('events_json_valid')])
|
|
271
|
+
.where('ai_chat_messages.id', '=', messageId).where('role', '=', 'assistant')
|
|
272
|
+
.where('owner_kind', '=', 'user').where('owner_id', '=', principalId).where('scope_id', '=', workspaceId)
|
|
273
|
+
.executeTakeFirst());
|
|
274
|
+
if (!row)
|
|
275
|
+
return null;
|
|
276
|
+
if (row.cursor === cursor)
|
|
277
|
+
return { cursor: row.cursor };
|
|
278
|
+
const metadata = JSON.stringify(toMessage({ ...row, events_json: null }));
|
|
279
|
+
const events = validatedEventsJson({ row });
|
|
280
|
+
const messageJson = events === undefined ? metadata : metadata.slice(0, -1) + ',"events":' + events + '}';
|
|
281
|
+
return { cursor: row.cursor, conversationId: row.conversation_id, messageJson };
|
|
282
|
+
}
|
|
216
283
|
/** Unlocked probe: the message and conversation an attempt id currently names. */
|
|
217
284
|
async function probeRun({ runId }, _options = {}) {
|
|
218
285
|
return kernel.run((db) => db.selectFrom("ai_chat_messages").select(["id", "conversation_id"])
|
|
@@ -267,7 +334,7 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
267
334
|
const history = await lockOwnedConversation({ required });
|
|
268
335
|
if (!history)
|
|
269
336
|
return null;
|
|
270
|
-
const existing = await load({ messageId: required.messageId }, {});
|
|
337
|
+
const existing = await load({ messageId: required.messageId }, { includeTranscriptEvents: false });
|
|
271
338
|
if (existing)
|
|
272
339
|
return sameOwner({ run: existing, required }) ? existing : null;
|
|
273
340
|
await assertSlotFree({ conversationId: required.conversationId });
|
|
@@ -282,7 +349,7 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
282
349
|
recovery_elapsed_ms: 0, attempt_started_at: required.now,
|
|
283
350
|
session_id: null, session_confirmed: 0, child_pid: null, child_started_at: null, attempt_base_json: "[]",
|
|
284
351
|
}).execute());
|
|
285
|
-
return load({ messageId: required.messageId }, {});
|
|
352
|
+
return load({ messageId: required.messageId }, { includeTranscriptEvents: false });
|
|
286
353
|
});
|
|
287
354
|
}
|
|
288
355
|
async function writeAttemptMetadata({ values, updates }) {
|
|
@@ -340,18 +407,18 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
340
407
|
const history = await lockOwnedConversation({ required });
|
|
341
408
|
if (!history)
|
|
342
409
|
return null;
|
|
343
|
-
const existing = await load({ messageId: required.messageId }, {});
|
|
410
|
+
const existing = await load({ messageId: required.messageId }, { includeTranscriptEvents: false });
|
|
344
411
|
if (existing) {
|
|
345
412
|
if (!sameOwner({ run: existing, required }))
|
|
346
413
|
return null;
|
|
347
414
|
await cancel({ runId: existing.runId, reason: "user-stop" }, {});
|
|
348
|
-
return load({ messageId: existing.messageId }, {});
|
|
415
|
+
return load({ messageId: existing.messageId }, { includeTranscriptEvents: false });
|
|
349
416
|
}
|
|
350
417
|
// Stop may beat acceptance or its response. A terminal tombstone for the same logical
|
|
351
418
|
// identity makes any later duplicate acceptance a no-op, without reserving the chat slot.
|
|
352
419
|
await history.appendMessage({ conversationId: required.conversationId, message: { id: required.messageId, role: "assistant", content: "",
|
|
353
420
|
events: [{ kind: "status", label: "Stopped." }], runId: required.runId, runStatus: "canceled", createdAt: required.now, endedAt: required.now } });
|
|
354
|
-
return load({ messageId: required.messageId }, {});
|
|
421
|
+
return load({ messageId: required.messageId }, { includeTranscriptEvents: false });
|
|
355
422
|
});
|
|
356
423
|
}
|
|
357
424
|
async function saveAgentSession({ run, sessionId }) {
|
|
@@ -430,6 +497,6 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
|
|
|
430
497
|
await writeAttemptMetadata({ values: { ...row, recovery_deadline: deadline, recovery_elapsed_ms: elapsed }, updates: { recovery_deadline: deadline, recovery_elapsed_ms: elapsed } });
|
|
431
498
|
});
|
|
432
499
|
}
|
|
433
|
-
return { load, find, accept, advance, cancel, cancelPending, captureSession, clearSession, guardTool, completeTool, recoveryClock };
|
|
500
|
+
return { load, head, poll, find, accept, advance, cancel, cancelPending, captureSession, clearSession, guardTool, completeTool, recoveryClock };
|
|
434
501
|
}
|
|
435
502
|
//# sourceMappingURL=durable-run-store.js.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { AgentEvent } from "../../core/index.js";
|
|
2
|
+
/** Replace a compact array's suffix without transferring the stored prefix to JS.
|
|
3
|
+
* SQL length/substr count characters on SQLite and PostgreSQL, including non-BMP text.
|
|
4
|
+
* @complexity One SQL string splice; only the new suffix crosses the kernel boundary. */
|
|
5
|
+
export declare function spliceEventsJson({ removeCharacters, suffix }: {
|
|
6
|
+
removeCharacters: number;
|
|
7
|
+
suffix: string;
|
|
8
|
+
}, _options?: {}): import("kysely").RawBuilder<string>;
|
|
9
|
+
/** `events_json` with one more event, built in SQL: no dialect parses or re-serializes the saved
|
|
10
|
+
* array in JS. Writers store `JSON.stringify` output (no whitespace), so a non-empty array is
|
|
11
|
+
* `[...]` and gains `,event`; `[]`, NULL and anything not array-shaped (a truncated legacy value,
|
|
12
|
+
* which the old JS read also treated as no events) become `[event]`. Plain `substr`/`length`/`||`
|
|
13
|
+
* run unchanged on SQLite and PostgreSQL. */
|
|
14
|
+
export declare function appendedEventsJson({ event }: {
|
|
15
|
+
event: AgentEvent;
|
|
16
|
+
}, _optional?: {}): import("kysely").RawBuilder<string>;
|
|
17
|
+
//# sourceMappingURL=events-json.d.ts.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
/** Replace a compact array's suffix without transferring the stored prefix to JS.
|
|
3
|
+
* SQL length/substr count characters on SQLite and PostgreSQL, including non-BMP text.
|
|
4
|
+
* @complexity One SQL string splice; only the new suffix crosses the kernel boundary. */
|
|
5
|
+
export function spliceEventsJson({ removeCharacters, suffix }, _options = {}) {
|
|
6
|
+
return sql `substr(events_json, 1, length(events_json) - ${removeCharacters}) || cast(${suffix} as text)`;
|
|
7
|
+
}
|
|
8
|
+
/** `events_json` with one more event, built in SQL: no dialect parses or re-serializes the saved
|
|
9
|
+
* array in JS. Writers store `JSON.stringify` output (no whitespace), so a non-empty array is
|
|
10
|
+
* `[...]` and gains `,event`; `[]`, NULL and anything not array-shaped (a truncated legacy value,
|
|
11
|
+
* which the old JS read also treated as no events) become `[event]`. Plain `substr`/`length`/`||`
|
|
12
|
+
* run unchanged on SQLite and PostgreSQL. */
|
|
13
|
+
export function appendedEventsJson({ event }, _optional = {}) {
|
|
14
|
+
const item = JSON.stringify(event);
|
|
15
|
+
const suffix = spliceEventsJson({ removeCharacters: 1, suffix: ',' + item + ']' });
|
|
16
|
+
const empty = '[' + item + ']';
|
|
17
|
+
return sql `case when events_json like '[_%]' then ${suffix} else cast(${empty} as text) end`;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=events-json.js.map
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/** Bind the server-owned run write fence to an already owner-scoped transcript. */
|
|
2
2
|
export function createRunGuardedChatStore({ store, ledger, isDaemonRunId }, _optional = {}) {
|
|
3
|
+
async function savedMessage({ conversationId, messageId }, _optional = {}) {
|
|
4
|
+
if (store.message)
|
|
5
|
+
return store.message({ conversationId, messageId }, {});
|
|
6
|
+
// Compatibility for hosts/fakes without the indexed read capability.
|
|
7
|
+
return (await store.messages({ conversationId })).find(message => message.id === messageId) ?? null;
|
|
8
|
+
}
|
|
3
9
|
return {
|
|
4
10
|
...store,
|
|
5
11
|
/*
|
|
@@ -24,7 +30,7 @@ export function createRunGuardedChatStore({ store, ledger, isDaemonRunId }, _opt
|
|
|
24
30
|
// attempt id or a transport-only failure; returning the stored row fences both without
|
|
25
31
|
// matching presentation text. BYOK/AG-UI remain request-bound.
|
|
26
32
|
if (isDaemonRunId({ runId })) {
|
|
27
|
-
const saved =
|
|
33
|
+
const saved = await savedMessage({ conversationId, messageId: message.id });
|
|
28
34
|
if (saved)
|
|
29
35
|
return saved;
|
|
30
36
|
if (message.runStatus !== "queued" && message.runStatus !== "running")
|
|
@@ -34,8 +40,7 @@ export function createRunGuardedChatStore({ store, ledger, isDaemonRunId }, _opt
|
|
|
34
40
|
} }, {});
|
|
35
41
|
if (outcome.written)
|
|
36
42
|
return outcome.value;
|
|
37
|
-
|
|
38
|
-
return saved.find((m) => m.id === message.id) ?? null;
|
|
43
|
+
return savedMessage({ conversationId, messageId: message.id });
|
|
39
44
|
},
|
|
40
45
|
};
|
|
41
46
|
}
|
|
@@ -6,8 +6,8 @@ export { runLockKey } from "../../core/durable-runs/run-lock.js";
|
|
|
6
6
|
/**
|
|
7
7
|
* @param store the chat kernel, or the open `chat.db` handle `createChatStoreFactory` also wraps.
|
|
8
8
|
* @param now Injectable epoch-millisecond clock for progress stamps and the default cancel time.
|
|
9
|
-
* @complexity
|
|
10
|
-
*
|
|
9
|
+
* @complexity indexed guards; checkpoints cost O(delta bytes) plus a SQLite scalar receipt read,
|
|
10
|
+
* or O(saved + incoming bytes) on fallback. Reconcile costs O(stuck rows + their event bytes).
|
|
11
11
|
*/
|
|
12
12
|
export declare function createChatRunLedger<DB extends ChatDatabase>({ kernel: hostKernel, isDaemonRunId }: {
|
|
13
13
|
kernel: ChatKernel<DB>;
|