@jini-ai/chat 0.6.6 → 0.6.10

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 (160) hide show
  1. package/CHANGELOG.md +129 -1
  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 +187 -98
  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 +31 -6
  21. package/dist/core/durable-runs/ledger-contracts.d.ts +2 -1
  22. package/dist/core/durable-runs/ports.d.ts +6 -2
  23. package/dist/core/index.d.ts +2 -0
  24. package/dist/core/index.js +2 -0
  25. package/dist/core/messages.d.ts +2 -0
  26. package/dist/core/partial-json.d.ts +6 -7
  27. package/dist/core/partial-json.js +81 -47
  28. package/dist/core/persistence/title.js +4 -2
  29. package/dist/core/question-answer.d.ts +23 -0
  30. package/dist/core/question-answer.js +66 -0
  31. package/dist/core/question-form/format.d.ts +6 -6
  32. package/dist/core/question-form/format.js +16 -17
  33. package/dist/core/question-form/normalize.d.ts +18 -0
  34. package/dist/core/question-form/normalize.js +112 -122
  35. package/dist/core/question-form/scan.js +141 -203
  36. package/dist/core/run-activity.js +62 -49
  37. package/dist/core/run-events/entry.d.ts +10 -1
  38. package/dist/core/run-events/entry.js +117 -87
  39. package/dist/core/todos.d.ts +1 -1
  40. package/dist/core/todos.js +60 -85
  41. package/dist/core/tool-events.js +14 -12
  42. package/dist/core/transcript.js +62 -16
  43. package/dist/core/turn-usage.js +54 -32
  44. package/dist/core/user-text-redaction.js +3 -0
  45. package/dist/core/util/manifest.js +77 -82
  46. package/dist/core/util/markdown-context.js +51 -53
  47. package/dist/core/util/parser.js +136 -144
  48. package/dist/core/util/pointer.js +16 -16
  49. package/dist/core/util/strip.js +57 -67
  50. package/dist/core/util/validate.js +15 -17
  51. package/dist/embed-widget/browser/session-store.js +21 -12
  52. package/dist/embed-widget/transports/fetch-sse.js +108 -95
  53. package/dist/embed-widget/vanilla/index.js +81 -70
  54. package/dist/react/agent-bridge/frontend-session-bridge.js +80 -52
  55. package/dist/react/components/AttachmentPreviewModal.js +25 -1
  56. package/dist/react/components/ChatSettingsDialog.d.ts +13 -0
  57. package/dist/react/components/ChatSettingsDialog.hooks.d.ts +115 -0
  58. package/dist/react/components/ChatSettingsDialog.hooks.js +40 -0
  59. package/dist/react/components/ChatSettingsDialog.js +72 -0
  60. package/dist/react/components/ChatSettingsSegmented.d.ts +18 -0
  61. package/dist/react/components/ChatSettingsSegmented.js +11 -0
  62. package/dist/react/components/Composer.d.ts +5 -1
  63. package/dist/react/components/Composer.js +126 -82
  64. package/dist/react/components/ConversationList.js +25 -39
  65. package/dist/react/components/ConversationTitleEditor.d.ts +27 -0
  66. package/dist/react/components/ConversationTitleEditor.js +54 -0
  67. package/dist/react/components/Icon.d.ts +1 -1
  68. package/dist/react/components/Icon.js +25 -45
  69. package/dist/react/components/Markdown.js +134 -123
  70. package/dist/react/components/McpUiSurfaceCard.d.ts +2 -2
  71. package/dist/react/components/McpUiSurfaceCard.js +30 -9
  72. package/dist/react/components/MessageList.d.ts +13 -13
  73. package/dist/react/components/MessageList.hooks.d.ts +6 -1
  74. package/dist/react/components/MessageList.hooks.js +19 -3
  75. package/dist/react/components/MessageList.js +34 -10
  76. package/dist/react/components/MessageRow.d.ts +1 -1
  77. package/dist/react/components/MessageRow.js +75 -47
  78. package/dist/react/components/MessageRow.usage.js +16 -11
  79. package/dist/react/components/MidRunUserMessage.js +16 -3
  80. package/dist/react/components/QuestionForm.js +148 -76
  81. package/dist/react/components/QuestionsPanel.js +1 -1
  82. package/dist/react/components/TodoCard.js +12 -6
  83. package/dist/react/components/ToolCallGroup.d.ts +4 -3
  84. package/dist/react/components/ToolCallGroup.js +7 -5
  85. package/dist/react/components/ToolCard.js +47 -40
  86. package/dist/react/components/composer-discovery.js +6 -1
  87. package/dist/react/components/mcp-ui-surface-state.d.ts +6 -11
  88. package/dist/react/components/mcp-ui-surface-state.js +23 -8
  89. package/dist/react/ext-event-renderer-registry.d.ts +8 -0
  90. package/dist/react/ext-event-renderer-registry.js +5 -2
  91. package/dist/react/features/chat-pane/components/AgentPluginUsage.d.ts +7 -0
  92. package/dist/react/features/chat-pane/components/AgentPluginUsage.js +45 -0
  93. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +55 -19
  94. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  95. package/dist/react/features/chat-pane/components/ChatPane.js +192 -74
  96. package/dist/react/features/chat-pane/components/RuntimeDropUp.d.ts +29 -0
  97. package/dist/react/features/chat-pane/components/RuntimeDropUp.js +45 -0
  98. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +80 -47
  99. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +44 -40
  100. package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +6 -6
  101. package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -8
  102. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +3 -3
  103. package/dist/react/features/chat-pane/create-typed-answer-poster.js +3 -3
  104. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts +6 -1
  105. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +11 -5
  106. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +12 -7
  107. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +340 -149
  108. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +7 -6
  109. package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +28 -10
  110. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +3 -3
  111. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +60 -12
  112. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.d.ts +49 -0
  113. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +143 -0
  114. package/dist/react/features/chat-pane/model-options.d.ts +14 -0
  115. package/dist/react/features/chat-pane/model-options.js +45 -25
  116. package/dist/react/features/chat-pane/rules.d.ts +8 -9
  117. package/dist/react/features/chat-pane/rules.js +84 -46
  118. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  119. package/dist/react/features/chat-pane/styles.js +152 -36
  120. package/dist/react/features/chat-pane/types.d.ts +19 -9
  121. package/dist/react/hooks/assistant-chats.js +12 -5
  122. package/dist/react/hooks/composer-draft-cache.js +116 -118
  123. package/dist/react/hooks/composer-history-storage.js +5 -5
  124. package/dist/react/hooks/fab-position.js +7 -4
  125. package/dist/react/hooks/useAnsweredCards.d.ts +21 -4
  126. package/dist/react/hooks/useAnsweredCards.js +38 -11
  127. package/dist/react/hooks/useArtifactStream.js +28 -22
  128. package/dist/react/hooks/useComposer.d.ts +20 -0
  129. package/dist/react/hooks/useComposer.js +22 -9
  130. package/dist/react/hooks/useComposerHistory.hooks.d.ts +6 -1
  131. package/dist/react/hooks/useComposerHistory.hooks.js +39 -19
  132. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +24 -14
  133. package/dist/react/hooks/useConversation.d.ts +7 -0
  134. package/dist/react/hooks/useConversation.js +23 -10
  135. package/dist/react/hooks/useExtEventGroups.js +39 -39
  136. package/dist/react/hooks/useQuestionForms.js +21 -26
  137. package/dist/react/hooks/useRunStream.js +10 -2
  138. package/dist/react/index.d.ts +7 -1
  139. package/dist/react/index.js +9 -0
  140. package/dist/react/message-blocks.d.ts +6 -4
  141. package/dist/react/message-blocks.js +94 -72
  142. package/dist/react/slots.d.ts +2 -2
  143. package/dist/react/tool-call-display.d.ts +12 -6
  144. package/dist/react/tool-call-display.js +53 -24
  145. package/dist/server/durable-runs/recover.js +132 -80
  146. package/dist/server/run-finalizer/entry.js +25 -15
  147. package/dist/store/legacy/sqlite/conversations.d.ts +11 -11
  148. package/dist/store/legacy/sqlite/conversations.js +69 -98
  149. package/dist/store/legacy/sqlite/messages.d.ts +10 -2
  150. package/dist/store/legacy/sqlite/messages.js +113 -71
  151. package/dist/store/runtime/chat-history-store.js +9 -3
  152. package/dist/store/runtime/durable-run-store.d.ts +2 -2
  153. package/dist/store/runtime/durable-run-store.js +165 -117
  154. package/dist/store/runtime/history-tools.js +118 -82
  155. package/dist/store/runtime/run-ledger.d.ts +4 -1
  156. package/dist/store/runtime/run-ledger.js +56 -16
  157. package/dist/store/sql/cursor.js +38 -18
  158. package/dist/store/sql/store.d.ts +18 -0
  159. package/dist/store/sql/store.js +46 -15
  160. package/package.json +18 -16
@@ -1,25 +1,33 @@
1
1
  import { recoveredRunEvents } from "../../core/index.js";
2
2
  import { chatKernelTables } from "../sql/boundary.js";
3
+ import { sql } from "kysely";
3
4
  import { hasUnknownToolCall, CONTINUATION_DIVIDER } from "../../core/durable-runs/continuation.js";
4
5
  import { RECOVERY_WINDOW_MS } from "../../core/durable-runs/policies.js";
5
6
  import { createChatHistoryStore } from "./chat-history-store.js";
6
7
  import { runLockKey } from "../../core/durable-runs/run-lock.js";
7
8
  import { runContentFromEvents } from "../../core/run-events/entry.js";
9
+ import { toMessage } from "../sql/store.js";
8
10
  export const RUN_SLOT_BUSY = "Another answer in this chat is still running. This turn is queued in your browser.";
9
11
  export class RunSlotBusyError extends Error {
10
12
  constructor() { super(RUN_SLOT_BUSY); }
11
13
  }
14
+ function isAcceptedUserShape({ message }, _optional = {}) {
15
+ return Boolean(message) && typeof message.id === "string" && Boolean(message.id) && message.role === "user" && typeof message.content === "string"
16
+ && (message.createdAt === undefined || Number.isFinite(message.createdAt));
17
+ }
18
+ function areAcceptedAttachments({ attachments }, _optional = {}) {
19
+ return Array.isArray(attachments) && attachments.every((attachment) => attachment && typeof attachment.path === "string" && typeof attachment.name === "string"
20
+ && (attachment.kind === "file" || attachment.kind === "image"));
21
+ }
12
22
  function acceptedUserMessage({ request, now }, _optional = {}) {
13
23
  const context = JSON.parse(request.contextRef);
14
24
  // Older clients supplied only a prompt; never invent a user identity from that transcript.
15
25
  const message = context.userMessage;
16
26
  if (message === undefined)
17
27
  return null;
18
- if (!message || typeof message.id !== "string" || !message.id || message.role !== "user" || typeof message.content !== "string"
19
- || (message.createdAt !== undefined && !Number.isFinite(message.createdAt)))
28
+ if (!isAcceptedUserShape({ message }, {}))
20
29
  throw new Error("Invalid accepted user message");
21
- if (message.attachments !== undefined && (!Array.isArray(message.attachments) || !message.attachments.every((attachment) => attachment && typeof attachment.path === "string" && typeof attachment.name === "string"
22
- && (attachment.kind === "file" || attachment.kind === "image"))))
30
+ if (message.attachments !== undefined && !areAcceptedAttachments({ attachments: message.attachments }, {}))
23
31
  throw new Error("Invalid accepted user attachments");
24
32
  // User input cannot claim an assistant run's status/events. Keep the same identity and payload
25
33
  // as its browser PUT, including the original clock and attachment order.
@@ -27,9 +35,10 @@ function acceptedUserMessage({ request, now }, _optional = {}) {
27
35
  ...(message.attachments ? { attachments: message.attachments } : {}) };
28
36
  }
29
37
  function recoveryFields({ row, metadata }, _optional = {}) {
30
- return { recoveryCount: metadata?.recovery_count ?? 0, recoveryDeadline: metadata?.recovery_deadline ?? null,
31
- attemptStartedAt: metadata?.attempt_started_at ?? 0, lastProgressAt: metadata?.last_progress_at ?? row.started_at ?? row.created_at,
32
- cancelReason: metadata?.cancel_reason ?? null };
38
+ const saved = metadata ?? {};
39
+ return { recoveryCount: saved.recovery_count ?? 0, recoveryDeadline: saved.recovery_deadline ?? null,
40
+ attemptStartedAt: saved.attempt_started_at ?? 0, lastProgressAt: saved.last_progress_at ?? row.started_at ?? row.created_at,
41
+ cancelReason: saved.cancel_reason ?? null };
33
42
  }
34
43
  function sessionFields({ metadata }, _optional = {}) {
35
44
  return { sessionId: metadata?.session_id ?? null, sessionConfirmed: metadata?.session_confirmed === 1,
@@ -42,6 +51,16 @@ function acceptedRequest({ row, metadata, transcript }, _optional = {}) {
42
51
  return { agentId: row.agent_id ?? "claude", contextRef: JSON.stringify({ conversationId: row.conversation_id,
43
52
  prompt: transcript.map((item) => `${item.role}: ${item.content}`).join("\n\n") }) };
44
53
  }
54
+ function runHead({ row, runId, metadata, message, transcript }, { isDaemonRunId }) {
55
+ return {
56
+ messageId: row.id, conversationId: row.conversation_id, runId,
57
+ workspaceId: row.scope_id,
58
+ principalId: row.owner_kind === "user" ? row.owner_id : null,
59
+ engine: metadata?.engine ?? (isDaemonRunId({ runId }) ? "daemon" : "request"), message,
60
+ request: acceptedRequest({ row, metadata, transcript }, {}),
61
+ ...recoveryFields({ row, metadata }, {}), ...sessionFields({ metadata }, {}),
62
+ };
63
+ }
45
64
  function metadataForRun({ run }, _optional = {}) {
46
65
  return { message_id: run.messageId, engine: run.engine, accepted_json: JSON.stringify(run.request),
47
66
  recovery_count: run.recoveryCount, recovery_deadline: run.recoveryDeadline, recovery_elapsed_ms: 0,
@@ -61,33 +80,31 @@ export function savedRunEvents({ json }, _optional = {}) {
61
80
  return [];
62
81
  }
63
82
  }
64
- function continuationEvents({ current, incoming }, _optional = {}) {
65
- let separator = -1;
66
- // Scan backward to keep the latest separator without requiring ES2023 array methods.
67
- for (let index = incoming.length - 1; index >= 0; index--) {
68
- const event = incoming[index];
69
- if (event.kind === "text" && event.text === CONTINUATION_DIVIDER) {
70
- separator = index;
71
- break;
72
- }
73
- }
83
+ /** Index of the latest matching event, or -1. */
84
+ function lastIndexOf({ events, matches }, _optional = {}) {
85
+ // Scan backward to keep the latest match without requiring ES2023 array methods.
86
+ for (let index = events.length - 1; index >= 0; index--)
87
+ if (matches(events[index]))
88
+ return index;
89
+ return -1;
90
+ }
91
+ /** Saved projection, with legacy content restored as text when no text event carries it. */
92
+ function savedProjection({ current }, _optional = {}) {
74
93
  const saved = [...(current.message.events ?? [])];
75
94
  if (!saved.some((event) => event.kind === "text") && current.message.content)
76
95
  saved.unshift({ kind: "text", text: current.message.content });
96
+ return saved;
97
+ }
98
+ function continuationEvents({ current, incoming }, _optional = {}) {
99
+ const separator = lastIndexOf({ events: incoming, matches: (event) => event.kind === "text" && event.text === CONTINUATION_DIVIDER });
100
+ const saved = savedProjection({ current });
77
101
  // A trailing checkpoint/tool barrier may land after the recovery probe but before its CAS.
78
102
  // Build the continuation segment from the projection inside the transaction, never that probe.
79
103
  if (separator >= 0)
80
104
  return [...saved, ...incoming.slice(separator)];
81
105
  // Empty startup retries have a recovery marker but no divider. Rebase their saved work under
82
106
  // the same lock too: a checkpoint may have arrived since the recovery probe.
83
- let marker = -1;
84
- for (let index = incoming.length - 1; index >= 0; index--) {
85
- const event = incoming[index];
86
- if (event.kind === "status" && event.code === "run_recovering") {
87
- marker = index;
88
- break;
89
- }
90
- }
107
+ const marker = lastIndexOf({ events: incoming, matches: (event) => event.kind === "status" && event.code === "run_recovering" });
91
108
  if (marker >= 0) {
92
109
  const event = incoming[marker];
93
110
  const notice = event.kind === 'status' && event.label === '' ? 'silent' : 'visible';
@@ -95,87 +112,132 @@ function continuationEvents({ current, incoming }, _optional = {}) {
95
112
  }
96
113
  return runContentFromEvents({ events: incoming }).length >= current.message.content.length ? incoming : saved;
97
114
  }
115
+ /** `events_json` with one more event, built in SQL: no dialect parses or re-serializes the saved
116
+ * array in JS. Writers store `JSON.stringify` output (no whitespace), so a non-empty array is
117
+ * `[...]` and gains `,event`; `[]`, NULL and anything not array-shaped (a truncated legacy value,
118
+ * which the old JS read also treated as no events) become `[event]`. Plain `substr`/`length`/`||`
119
+ * run unchanged on SQLite and PostgreSQL. */
120
+ function appendedEventsJson({ event }, _optional = {}) {
121
+ const item = sql `cast(${JSON.stringify(event)} as text)`;
122
+ return sql `case when events_json like '[_%]' then substr(events_json, 1, length(events_json) - 1) || ',' || ${item} || ']' else '[' || ${item} || ']' end`;
123
+ }
124
+ function sameOwner({ run, required }, _optional = {}) {
125
+ return run.principalId === required.principalId && run.conversationId === required.conversationId;
126
+ }
98
127
  /** Same kernel and message lock as checkpoint/settle. Claim changes run_id before any spawn;
99
128
  * old attempts cannot write projections or dispatch tools after that transaction commits. */
100
129
  export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _optional = {}) {
101
130
  const kernel = chatKernelTables({ kernel: hostKernel }, {});
102
- async function loadSnapshot({ messageId }, _options = {}) {
103
- const row = await kernel.run((db) => db.selectFrom("ai_chat_messages")
131
+ /** The run's own message row, with its events only when asked: never another message's. */
132
+ async function runRow({ match }, { events = false } = {}) {
133
+ const eventsColumn = events ? sql `${sql.ref("ai_chat_messages.events_json")}` : sql `null`;
134
+ return kernel.run((db) => db.selectFrom("ai_chat_messages")
104
135
  .innerJoin("ai_chats", "ai_chats.id", "ai_chat_messages.conversation_id")
105
- .selectAll("ai_chat_messages").select(["owner_kind", "owner_id", "scope_id"])
106
- .where("ai_chat_messages.id", "=", messageId).where("role", "=", "assistant").executeTakeFirst());
136
+ .select(["ai_chat_messages.id", "conversation_id", "role", "content", "agent_id", "agent_name", "attachments_json",
137
+ "run_id", "run_status", "position", "ai_chat_messages.created_at", "started_at", "ended_at", "owner_kind", "owner_id", "scope_id"])
138
+ .select(eventsColumn.as("events_json"))
139
+ .where(match.column, "=", match.value).where("role", "=", "assistant").executeTakeFirst());
140
+ }
141
+ /** Every message of the run's conversation; only `load` and legacy rows without attempt metadata pay this. */
142
+ async function conversationTranscript({ row }, _options = {}) {
143
+ const history = createChatHistoryStore({ kernel, isDaemonRunId, scope: { scopeId: row.scope_id, ownerKind: row.owner_kind, ownerId: row.owner_id } }, {});
144
+ return history.messages({ conversationId: row.conversation_id });
145
+ }
146
+ /** One run's head, plus its transcript when that was read (`[]` otherwise). */
147
+ async function readRun({ match }, options = {}) {
148
+ const row = await runRow({ match }, options);
107
149
  if (!row || !row.run_id)
108
150
  return null;
151
+ const messageId = row.id;
109
152
  const metadata = await kernel.run((db) => db.selectFrom("assistant_run_attempts").selectAll().where("message_id", "=", messageId).executeTakeFirst());
110
- const history = createChatHistoryStore({ kernel, isDaemonRunId, scope: { scopeId: row.scope_id, ownerKind: row.owner_kind, ownerId: row.owner_id } }, {});
111
- const transcript = await history.messages({ conversationId: row.conversation_id });
112
- const message = transcript.find((item) => item.id === messageId);
153
+ // A legacy row's accepted request is derived from its transcript, so only it reads one here.
154
+ const transcript = options.transcript || !metadata ? await conversationTranscript({ row }) : [];
155
+ const message = options.transcript ? transcript.find((item) => item.id === messageId) : toMessage(row);
113
156
  if (!message)
114
157
  return null; // Deletion may have cascaded between the two reads.
115
- const request = acceptedRequest({ row, metadata, transcript }, {});
116
- return {
117
- messageId, conversationId: row.conversation_id, runId: row.run_id,
118
- workspaceId: row.scope_id,
119
- principalId: row.owner_kind === "user" ? row.owner_id : null,
120
- engine: metadata?.engine ?? (isDaemonRunId({ runId: row.run_id }) ? "daemon" : "request"), message, transcript, request,
121
- ...recoveryFields({ row, metadata }, {}), ...sessionFields({ metadata }, {}),
122
- };
158
+ return { head: runHead({ row, runId: row.run_id, metadata, message, transcript }, { isDaemonRunId }), transcript };
159
+ }
160
+ /** Locked read of one message; `transcript` decides whether it is a whole run or only its head. */
161
+ async function lockedRead({ messageId, conversationId }, options = {}) {
162
+ return kernel.transaction(async () => {
163
+ // Message, attempt metadata and transcript must describe the same generation. A CAS in
164
+ // the gap between independent reads must never pair a new projection with an old session.
165
+ await kernel.lockKey(runLockKey({ messageId, conversationId, runId: "" }));
166
+ return readRun({ match: { column: "ai_chat_messages.id", value: messageId } }, options);
167
+ });
123
168
  }
124
169
  async function load({ messageId }, _options = {}) {
125
170
  const binding = await kernel.run((db) => db.selectFrom("ai_chat_messages").select("conversation_id")
126
171
  .where("id", "=", messageId).executeTakeFirst());
127
172
  if (!binding)
128
173
  return null;
129
- return kernel.transaction(async () => {
130
- // Message, attempt metadata and transcript must describe the same generation. A CAS in
131
- // the gap between independent reads must never pair a new projection with an old session.
132
- await kernel.lockKey(runLockKey({ messageId, conversationId: binding.conversation_id, runId: "" }));
133
- return loadSnapshot({ messageId }, {});
134
- });
174
+ // The message comes from the transcript, so its row's events are not selected twice.
175
+ const read = await lockedRead({ messageId, conversationId: binding.conversation_id }, { transcript: true });
176
+ return read && { ...read.head, transcript: read.transcript };
177
+ }
178
+ /** Unlocked probe: the message and conversation an attempt id currently names. */
179
+ async function probeRun({ runId }, _options = {}) {
180
+ return kernel.run((db) => db.selectFrom("ai_chat_messages").select(["id", "conversation_id"])
181
+ .where("run_id", "=", runId).where("role", "=", "assistant").executeTakeFirst());
135
182
  }
136
183
  async function find({ runId, principalId }, _options = {}) {
137
- const row = await kernel.run((db) => db.selectFrom("ai_chat_messages").select("id").where("run_id", "=", runId).where("role", "=", "assistant").executeTakeFirst());
184
+ const row = await probeRun({ runId });
138
185
  if (!row)
139
186
  return null;
140
- const run = await load({ messageId: row.id }, {});
187
+ const run = (await lockedRead({ messageId: row.id, conversationId: row.conversation_id }, { events: true }))?.head ?? null;
141
188
  return principalId !== undefined && run?.principalId !== principalId ? null : run;
142
189
  }
190
+ /** Slot and run locks, then the owner-scoped conversation; null when the caller does not own it. */
191
+ async function lockOwnedConversation({ required }, _options = {}) {
192
+ await kernel.lockKey(`chat-slot:${required.conversationId}`);
193
+ await kernel.lockKey(runLockKey(required));
194
+ const history = createChatHistoryStore({ kernel, isDaemonRunId, scope: { scopeId: required.workspaceId, ownerKind: "user", ownerId: required.principalId } }, {});
195
+ return await history.get({ id: required.conversationId }) ? history : null;
196
+ }
197
+ async function assertSlotFree({ conversationId }, _options = {}) {
198
+ const occupied = await kernel.run((db) => db.selectFrom("ai_chat_messages").select("id")
199
+ .where("conversation_id", "=", conversationId).where("role", "=", "assistant")
200
+ .where("run_status", "in", ["queued", "running"]).executeTakeFirst());
201
+ if (occupied)
202
+ throw new RunSlotBusyError();
203
+ }
204
+ async function appendAcceptedQuestion({ history, required, userMessage }, _options = {}) {
205
+ if (userMessage.id === required.messageId)
206
+ throw new Error("User and assistant messages must have different IDs");
207
+ // A pending/failed browser PUT must never let the assistant take the first position.
208
+ // Nested appends join this transaction and retain the conversation lock through both
209
+ // writes, so either arrival order of the idempotent PUT yields user then assistant.
210
+ const saved = await history.appendMessage({ conversationId: required.conversationId, message: userMessage });
211
+ if (!saved || saved.role !== "user")
212
+ throw new Error("Accepted user message could not be persisted");
213
+ }
214
+ /** @returns false when the answer id collides and no question was written. */
215
+ async function appendQueuedAnswer({ history, required, hasQuestion }, _options = {}) {
216
+ const message = { id: required.messageId, role: "assistant", content: "", events: [],
217
+ runId: required.runId, runStatus: "queued", createdAt: required.now, startedAt: required.now,
218
+ ...(required.request.agentId ? { agentId: required.request.agentId } : {}) };
219
+ const savedMessage = await history.appendMessage({ conversationId: required.conversationId, message });
220
+ if (savedMessage?.role === "assistant")
221
+ return true;
222
+ // Returning null here would commit the question alone after an ID collision.
223
+ if (hasQuestion)
224
+ throw new Error("Accepted assistant message could not be persisted");
225
+ return false;
226
+ }
143
227
  async function accept(required, _options = {}) {
144
228
  return kernel.transaction(async () => {
145
- await kernel.lockKey(`chat-slot:${required.conversationId}`);
146
- await kernel.lockKey(runLockKey(required));
147
- const history = createChatHistoryStore({ kernel, isDaemonRunId, scope: { scopeId: required.workspaceId, ownerKind: "user", ownerId: required.principalId } }, {});
148
- if (!await history.get({ id: required.conversationId }))
229
+ const history = await lockOwnedConversation({ required });
230
+ if (!history)
149
231
  return null;
150
232
  const existing = await load({ messageId: required.messageId }, {});
151
233
  if (existing)
152
- return existing.principalId === required.principalId && existing.conversationId === required.conversationId ? existing : null;
153
- const occupied = await kernel.run((db) => db.selectFrom("ai_chat_messages").select("id")
154
- .where("conversation_id", "=", required.conversationId).where("role", "=", "assistant")
155
- .where("run_status", "in", ["queued", "running"]).executeTakeFirst());
156
- if (occupied)
157
- throw new RunSlotBusyError();
234
+ return sameOwner({ run: existing, required }) ? existing : null;
235
+ await assertSlotFree({ conversationId: required.conversationId });
158
236
  const userMessage = acceptedUserMessage({ request: required.request, now: required.now }, {});
159
- if (userMessage) {
160
- if (userMessage.id === required.messageId)
161
- throw new Error("User and assistant messages must have different IDs");
162
- // A pending/failed browser PUT must never let the assistant take the first position.
163
- // Nested appends join this transaction and retain the conversation lock through both
164
- // writes, so either arrival order of the idempotent PUT yields user then assistant.
165
- const saved = await history.appendMessage({ conversationId: required.conversationId, message: userMessage });
166
- if (!saved || saved.role !== "user")
167
- throw new Error("Accepted user message could not be persisted");
168
- }
169
- const message = { id: required.messageId, role: "assistant", content: "", events: [],
170
- runId: required.runId, runStatus: "queued", createdAt: required.now, startedAt: required.now,
171
- ...(required.request.agentId ? { agentId: required.request.agentId } : {}) };
172
- const savedMessage = await history.appendMessage({ conversationId: required.conversationId, message });
173
- if (!savedMessage || savedMessage.role !== "assistant") {
174
- // Returning null here would commit the question alone after an ID collision.
175
- if (userMessage)
176
- throw new Error("Accepted assistant message could not be persisted");
237
+ if (userMessage)
238
+ await appendAcceptedQuestion({ history, required, userMessage });
239
+ if (!await appendQueuedAnswer({ history, required, hasQuestion: userMessage !== null }))
177
240
  return null;
178
- }
179
241
  await kernel.run((db) => db.insertInto("assistant_run_attempts").values({
180
242
  message_id: required.messageId, engine: "daemon", accepted_json: JSON.stringify(required.request),
181
243
  recovery_count: 0, recovery_deadline: null, last_progress_at: required.now, cancel_reason: null,
@@ -188,7 +250,8 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
188
250
  async function advance({ run, nextRunId, now, events }, _options = {}) {
189
251
  return kernel.transaction(async () => {
190
252
  await kernel.lockKey(runLockKey(run));
191
- const current = await load({ messageId: run.messageId }, {});
253
+ // The projection rebase needs this run's events, never the rest of its conversation.
254
+ const current = (await lockedRead(run, { events: true }))?.head;
192
255
  if (!current || current.cancelReason || current.runId !== run.runId)
193
256
  return false;
194
257
  const projection = continuationEvents({ current, incoming: events }, {});
@@ -212,28 +275,33 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
212
275
  return true;
213
276
  });
214
277
  }
278
+ /** Probe without the run lock, take it, then read: a run deleted or advanced in the gap reads as gone.
279
+ * One read under the lock serves the whole operation; the unlocked probe only names the lock.
280
+ * @returns The run as of the lock, or null when the attempt id is unknown or no longer current. */
281
+ async function lockedRun({ runId }, options = {}) {
282
+ const probe = await probeRun({ runId });
283
+ if (!probe)
284
+ return null;
285
+ await kernel.lockKey(runLockKey({ messageId: probe.id, conversationId: probe.conversation_id, runId }));
286
+ return (await readRun({ match: { column: "ai_chat_messages.run_id", value: runId } }, options))?.head ?? null;
287
+ }
215
288
  async function cancel({ runId, reason }, _options = {}) {
216
289
  await kernel.transaction(async () => {
217
- const run = await find({ runId }, {});
290
+ const run = await lockedRun({ runId });
218
291
  if (!run)
219
292
  return;
220
- await kernel.lockKey(runLockKey(run));
221
- if (!await find({ runId }, {}))
222
- return;
223
293
  await kernel.run((db) => db.insertInto("assistant_run_attempts").values({ ...metadataForRun({ run }, {}), cancel_reason: reason })
224
294
  .onConflict((oc) => oc.column("message_id").doUpdateSet({ cancel_reason: reason })).execute());
225
295
  });
226
296
  }
227
297
  async function cancelPending(required, _options = {}) {
228
298
  return kernel.transaction(async () => {
229
- await kernel.lockKey(`chat-slot:${required.conversationId}`);
230
- await kernel.lockKey(runLockKey(required));
231
- const history = createChatHistoryStore({ kernel, isDaemonRunId, scope: { scopeId: required.workspaceId, ownerKind: "user", ownerId: required.principalId } }, {});
232
- if (!await history.get({ id: required.conversationId }))
299
+ const history = await lockOwnedConversation({ required });
300
+ if (!history)
233
301
  return null;
234
302
  const existing = await load({ messageId: required.messageId }, {});
235
303
  if (existing) {
236
- if (existing.principalId !== required.principalId || existing.conversationId !== required.conversationId)
304
+ if (!sameOwner({ run: existing, required }))
237
305
  return null;
238
306
  await cancel({ runId: existing.runId, reason: "user-stop" }, {});
239
307
  return load({ messageId: existing.messageId }, {});
@@ -247,15 +315,11 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
247
315
  }
248
316
  async function captureSession({ runId, sessionId, child, confirmed = true }, _options = {}) {
249
317
  return kernel.transaction(async () => {
250
- const run = await find({ runId }, {});
251
- if (!run)
252
- return false;
253
- await kernel.lockKey(runLockKey(run));
254
- const current = await find({ runId }, {});
255
- if (!attemptCanExecute({ run: current }, {}) || !current)
318
+ const run = await lockedRun({ runId });
319
+ if (!run || !attemptCanExecute({ run }, {}))
256
320
  return false;
257
321
  const fields = { session_id: sessionId, session_confirmed: confirmed ? 1 : 0, ...(child ? { child_pid: child.pid, child_started_at: child.startedAt } : {}) };
258
- await kernel.run((db) => db.insertInto("assistant_run_attempts").values({ ...metadataForRun({ run: current }, {}), ...fields })
322
+ await kernel.run((db) => db.insertInto("assistant_run_attempts").values({ ...metadataForRun({ run }, {}), ...fields })
259
323
  .onConflict((oc) => oc.column("message_id").doUpdateSet(fields)).execute());
260
324
  // Session and generation share the same lock/transaction; an old callback cannot write
261
325
  // the conversation's locator in the gap between a successful guard and a later CAS.
@@ -267,11 +331,7 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
267
331
  }
268
332
  async function clearSession({ runId, sessionId }, _options = {}) {
269
333
  await kernel.transaction(async () => {
270
- const before = await find({ runId }, {});
271
- if (!before)
272
- return;
273
- await kernel.lockKey(runLockKey(before));
274
- const run = await find({ runId }, {});
334
+ const run = await lockedRun({ runId });
275
335
  if (!run)
276
336
  return;
277
337
  await kernel.run((db) => db.deleteFrom("assistant_agent_sessions").where("conversation_id", "=", run.conversationId)
@@ -282,14 +342,9 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
282
342
  }
283
343
  async function guardTool(required, _options = {}) {
284
344
  return kernel.transaction(async () => {
285
- const before = await find({ runId: required.runId }, {});
286
- if (!before)
287
- return "stale";
288
- await kernel.lockKey(runLockKey(before));
289
- const run = await find({ runId: required.runId }, {});
290
- if (!attemptCanExecute({ run }, {}))
291
- return "stale";
292
- if (!run)
345
+ // The unknown-call check needs this run's saved events (one row), never other messages'.
346
+ const run = await lockedRun({ runId: required.runId }, { events: true });
347
+ if (!run || !attemptCanExecute({ run }, {}))
293
348
  return "stale";
294
349
  const events = run.message.events ?? [];
295
350
  const canonical = events.filter((event) => event.kind !== "tool_use" || event.name === required.toolId);
@@ -301,7 +356,7 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
301
356
  });
302
357
  }
303
358
  async function writeToolEvent(run, event) {
304
- await kernel.run((db) => db.updateTable("ai_chat_messages").set({ events_json: JSON.stringify([...(run.message.events ?? []), event]) })
359
+ await kernel.run((db) => db.updateTable("ai_chat_messages").set({ events_json: appendedEventsJson({ event }) })
305
360
  .where("id", "=", run.messageId).where("run_id", "=", run.runId).where("run_status", "in", ["queued", "running"]).execute());
306
361
  await kernel.run((db) => db.updateTable("assistant_run_attempts")
307
362
  .set({ recovery_count: 0, recovery_deadline: null, recovery_elapsed_ms: 0, last_progress_at: Date.now() })
@@ -309,23 +364,16 @@ export function createDurableRunStore({ kernel: hostKernel, isDaemonRunId }, _op
309
364
  }
310
365
  async function completeTool({ runId, toolUseId, content }, _options = {}) {
311
366
  await kernel.transaction(async () => {
312
- const before = await find({ runId }, {});
313
- if (!before)
314
- return;
315
- await kernel.lockKey(runLockKey(before));
316
- const run = await find({ runId }, {});
367
+ const run = await lockedRun({ runId });
317
368
  if (run)
318
369
  await writeToolEvent(run, { kind: "tool_result", toolUseId, content, isError: false });
319
370
  });
320
371
  }
321
372
  async function recoveryClock({ runId, now, active }, _options = {}) {
322
373
  await kernel.transaction(async () => {
323
- const run = await find({ runId }, {});
374
+ const run = await lockedRun({ runId });
324
375
  if (!run)
325
376
  return;
326
- await kernel.lockKey(runLockKey(run));
327
- if (!await find({ runId }, {}))
328
- return;
329
377
  const row = await kernel.run((db) => db.selectFrom("assistant_run_attempts").selectAll().where("message_id", "=", run.messageId).executeTakeFirst())
330
378
  ?? metadataForRun({ run }, {});
331
379
  let deadline = row.recovery_deadline;