@jskit-ai/assistant-core 0.1.226 → 0.1.228

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 (37) hide show
  1. package/examples/conversation/package.json +1 -1
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +5 -3
  3. package/fixtures/responsive-assistant/DeliveryFixture.vue +9 -0
  4. package/package.json +1 -1
  5. package/src/client/conversation/AssistantConversationElement.vue +7 -4
  6. package/src/client/conversation/AssistantGoalControl.vue +31 -17
  7. package/src/client/conversation/AssistantTranscript.vue +14 -7
  8. package/src/client/conversation/useAssistantAttachments.js +6 -3
  9. package/src/server/conversation/claudeClient.js +22 -5
  10. package/src/server/conversation/claudeHistory.js +62 -5
  11. package/src/server/conversation/claudeProcess.js +7 -6
  12. package/src/server/conversation/claudeTurn.js +284 -57
  13. package/src/server/conversation/codexConfiguration.js +24 -1
  14. package/src/server/conversation/codexGoalCommands.js +8 -3
  15. package/src/server/conversation/codexTurnRecovery.js +18 -8
  16. package/src/server/conversation/continuity.js +54 -17
  17. package/src/server/conversation/providers/claudeDriver.js +6 -5
  18. package/src/server/conversation/providers/codexDriver.js +3 -3
  19. package/src/server/conversation/providers/scoped.js +31 -10
  20. package/src/server/conversation/runtime.js +48 -13
  21. package/src/server/conversation/streams.js +7 -3
  22. package/src/server/conversation/transcript.js +2 -1
  23. package/src/server/hosts/codexConfiguration.js +1 -0
  24. package/src/shared/conversation/longTextBlocks.js +0 -4
  25. package/test/assistantScroll.browser.test.js +76 -3
  26. package/test/claudeClient.test.js +73 -0
  27. package/test/claudeConversationSession.test.js +120 -4
  28. package/test/claudeProcess.test.js +61 -0
  29. package/test/claudeTurn.test.js +1326 -10
  30. package/test/codexConversationSession.test.js +9 -1
  31. package/test/codexGoal.test.js +47 -0
  32. package/test/codexModelCatalog.test.js +55 -0
  33. package/test/codexTurn.test.js +228 -0
  34. package/test/conversationAttachments.test.js +17 -0
  35. package/test/conversationRuntime.test.js +40 -0
  36. package/test/conversationStreaming.test.js +18 -0
  37. package/test/messageDelivery.browser.test.js +30 -0
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { execFile } from "node:child_process";
3
- import { open, rename, rm, writeFile } from "node:fs/promises";
3
+ import { open, readFile, rename, rm, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { isPlainObject as isRecord, normalizeText } from "./normalize.js";
@@ -196,6 +196,29 @@ export async function prepareCodexModelCatalog({ command, runtimeDir, bundled =
196
196
  return catalogPath;
197
197
  }
198
198
 
199
+ // Read the same catalogue supplied to this runtime's native model manager.
200
+ // max_context_window advertises an extended window; it is not the normal budget.
201
+ export async function readCodexModelContextWindow({ runtimeDir, model } = {}) {
202
+ if (typeof runtimeDir !== "string" || !path.isAbsolute(runtimeDir) ||
203
+ typeof model !== "string" || !model.trim()) {
204
+ throw new TypeError("A selected Codex runtime and exact model are required.");
205
+ }
206
+ try {
207
+ const catalog = JSON.parse(await readFile(path.join(runtimeDir, "models.json"), "utf8"));
208
+ const matches = catalog.models.filter(row => row?.slug === model);
209
+ if (matches.length !== 1) throw new Error("Unknown model");
210
+ const { context_window: windowTokens, effective_context_window_percent: percent } = matches[0];
211
+ if (!Number.isSafeInteger(windowTokens) || windowTokens < 1 ||
212
+ !Number.isSafeInteger(percent) || percent < 1 || percent > 100) throw new Error("Invalid context limit");
213
+ const effectiveWindowTokens = Math.floor(windowTokens * percent / 100);
214
+ if (effectiveWindowTokens < 1) throw new Error("Invalid effective context limit");
215
+ return { model, windowTokens, effectiveWindowTokens };
216
+ } catch {
217
+ // A native catalogue can contain private provider configuration.
218
+ throw new Error("Codex's normal context window is unavailable for the selected runtime and model.");
219
+ }
220
+ }
221
+
199
222
  const DISABLED_FEATURES = Object.freeze([
200
223
  "apps",
201
224
  "artifact",
@@ -38,9 +38,11 @@ export function createCodexGoalCommands({
38
38
 
39
39
  async function updateCodexAppServerGoal(sessionId, input = {}, {
40
40
  context: initialContext,
41
+ signal,
41
42
  prepareConversation
42
43
  } = {}) {
43
44
  let context = initialContext;
45
+ signal?.throwIfAborted();
44
46
  if (context.ok === false) return context;
45
47
  let threadId = context.threadId;
46
48
  if (threadId !== input.threadId || (!threadId && input.action !== "set")) {
@@ -59,10 +61,11 @@ export function createCodexGoalCommands({
59
61
  if (previousGoal && (previousGoal.status !== "complete" || previousGoal.createdAt !== input.createdAt)) {
60
62
  return { ok: false, error: "The Codex goal changed. Refresh it before setting a new goal." };
61
63
  }
64
+ signal?.throwIfAborted();
62
65
  subscribeCodexAppServerEvents(sessionId, context.provider, threadId, context);
63
66
  const result = await context.provider.setGoal(threadId, {
64
67
  objective: input.objective, tokenBudget: input.tokenBudget
65
- });
68
+ }, { signal });
66
69
  await reconcileCodexAppServerGoalUpdated(sessionId, context.provider, threadId, {
67
70
  params: { goal: result.goal }
68
71
  });
@@ -75,7 +78,7 @@ export function createCodexGoalCommands({
75
78
  }
76
79
  if (input.action === "cancel") {
77
80
  subscribeCodexAppServerEvents(sessionId, context.provider, threadId, context);
78
- await context.provider.clearGoal(threadId);
81
+ await context.provider.clearGoal(threadId, { signal });
79
82
  await reconcileCodexAppServerGoalUpdated(sessionId, context.provider, threadId, {
80
83
  method: "thread/goal/cleared",
81
84
  params: { threadId }
@@ -105,9 +108,11 @@ export function createCodexGoalCommands({
105
108
  patch: { providerStatus: "interrupted", error: "" }
106
109
  });
107
110
  });
111
+ signal?.throwIfAborted();
108
112
  await context.provider.resumeThread(threadId, context.resumeOptions);
109
113
  }
110
- const result = await context.provider.setGoalStatus(threadId, input.action === "pause" ? "paused" : "active");
114
+ signal?.throwIfAborted();
115
+ const result = await context.provider.setGoalStatus(threadId, input.action === "pause" ? "paused" : "active", { signal });
111
116
  await reconcileCodexAppServerGoalUpdated(sessionId, context.provider, threadId, {
112
117
  params: { goal: result.goal }
113
118
  });
@@ -54,6 +54,7 @@ export function createCodexTurnRecovery({
54
54
  finalizedTurns: codexAppServerFinalizedTurns,
55
55
  resultFinalizationKey: codexAppServerResultFinalizationKey,
56
56
  readAgentRunForSession: readCodexAppServerAgentRunForSession,
57
+ submitAssistantResult: submitCodexAppServerAssistantResult,
57
58
  cleanupUntrackedTurn: cleanupCodexAppServerUntrackedTurn
58
59
  } = output;
59
60
  const {
@@ -953,14 +954,23 @@ export function createCodexTurnRecovery({
953
954
  }
954
955
  return { ok: true, processed: false, reason: "failure_detail_pending", status: normalizedStatus };
955
956
  }
956
- const recovered = await recoverCodexAppServerFinalResponseBeforeOutcome(
957
- normalizedSessionId,
958
- normalizedThreadId,
959
- normalizedTurnId,
960
- normalizedStatus
961
- );
962
- if (recovered?.processed) {
963
- return recovered;
957
+ if (resolvedOutcome === turnOutcomes.PROVIDER_FAILURE) {
958
+ // Saved output is still useful, but cannot turn a known provider failure
959
+ // into successful completion. Persist it without settling the turn;
960
+ // the failure notice and checkpoint below own the terminal outcome.
961
+ await submitCodexAppServerAssistantResult(normalizedSessionId, normalizedThreadId, normalizedTurnId, {
962
+ recoverFromProvider: true
963
+ });
964
+ } else {
965
+ const recovered = await recoverCodexAppServerFinalResponseBeforeOutcome(
966
+ normalizedSessionId,
967
+ normalizedThreadId,
968
+ normalizedTurnId,
969
+ normalizedStatus
970
+ );
971
+ if (recovered?.processed) {
972
+ return recovered;
973
+ }
964
974
  }
965
975
  // History recovery awaited the provider. Confirm activity again before
966
976
  // publishing a failure, because the goal may have continued in that gap.
@@ -17,8 +17,13 @@ export function conversationMessageVersion(message, { includeData = false } = {}
17
17
  /** Keep native delivery and its history cursor in the same durable record. */
18
18
  export function createConversationChangeover({ state, transcript, agent, identity, presentation = {}, log = () => {},
19
19
  captureContext = false, applicationMessages = false, maximumInitialMessages = 30,
20
- maximumInitialMessageCharacters = Infinity }) {
21
- if (!Number.isSafeInteger(maximumInitialMessages) || maximumInitialMessages < 0 ||
20
+ maximumInitialMessageCharacters = Infinity, maximumHistoryBytes = Infinity }) {
21
+ if (typeof maximumHistoryBytes !== "function" && maximumHistoryBytes !== Infinity &&
22
+ (!Number.isSafeInteger(maximumHistoryBytes) || maximumHistoryBytes < 1) ||
23
+ maximumInitialMessages === Infinity && maximumHistoryBytes === Infinity) {
24
+ throw new TypeError("Native history requires a positive byte limit for unbounded message selection.");
25
+ }
26
+ if (maximumInitialMessages !== Infinity && (!Number.isSafeInteger(maximumInitialMessages) || maximumInitialMessages < 0) ||
22
27
  maximumInitialMessageCharacters !== Infinity &&
23
28
  (!Number.isSafeInteger(maximumInitialMessageCharacters) || maximumInitialMessageCharacters < 1)) {
24
29
  throw new TypeError("Initial native history requires a non-negative message limit and a positive text limit.");
@@ -167,12 +172,16 @@ export function createConversationChangeover({ state, transcript, agent, identit
167
172
 
168
173
  const provisional = messages.find((message) => (message.role === "user" || applicationMessages && message.role === "application") &&
169
174
  message.messageId === input.messageId && message.receipt === false);
170
- if (provisional) {
175
+ // The original pending write precedes every native dispatch. A retained
176
+ // false attempt for this exact input proves it has not crossed that boundary
177
+ // (or the native owner explicitly rejected it before writing).
178
+ const notAttempted = binding.pending?.messageId === input.messageId && binding.pending.attempted === false;
179
+ if (provisional && !notAttempted) {
171
180
  const receipt = await agent.inspectMessageAdmission({ messageId: input.messageId }).catch(() => null);
172
181
  if (receipt?.admission !== "accepted") return unconfirmed(input.messageId, receipt?.threadId);
173
182
  return { ok: true, delivered: true, messageId: input.messageId, duplicate: true };
174
183
  }
175
- if (await transcript.hasMessage(input.messageId)) {
184
+ if (!provisional && await transcript.hasMessage(input.messageId)) {
176
185
  return { ok: true, delivered: true, messageId: input.messageId, duplicate: true };
177
186
  }
178
187
  const snapshot = Object.fromEntries(messages.map((message) => [message.id, message.version]));
@@ -181,19 +190,52 @@ export function createConversationChangeover({ state, transcript, agent, identit
181
190
  const renderChanged = changed.filter(message => !excluded.has(message.messageId));
182
191
  const deleted = Object.keys(binding.seen).filter((id) => !Object.hasOwn(snapshot, id));
183
192
  const switched = value.lastEngine !== engineId;
184
- // A new native conversation gets the recent bubbles. A returning one gets
185
- // every missed/edited bubble, even if the edit is older than that window.
186
- const catchup = (returning ? changed : maximumInitialMessages ? messages.slice(-maximumInitialMessages) : [])
193
+ // Returning conversations select missed/edited bubbles independently of
194
+ // the initial message window. The total byte budget applies to both paths.
195
+ let catchup = (returning ? changed : maximumInitialMessages ? messages.slice(-maximumInitialMessages) : [])
187
196
  .filter(message => !excluded.has(message.messageId));
188
197
  let pending = binding.pending;
189
198
  if (pending && pending.messageId !== input.messageId) {
190
199
  delete binding.pending;
191
200
  pending = null;
192
201
  }
193
- if (!pending && (switched || renderChanged.length || deleted.length)) {
202
+ if (!pending) {
203
+ const needsContext = switched || renderChanged.length || deleted.length;
194
204
  const handover = value.replacement?.engineId === engineId && value.replacement.status === "ready"
195
205
  ? value.replacement.handover : "";
196
- const preamble = [
206
+ let historyBudget = maximumHistoryBytes;
207
+ if (typeof historyBudget === "function") {
208
+ // Native context inspection may prepare a runtime. Do it only when a
209
+ // new catch-up needs sizing, never for receipt recovery or exact retry.
210
+ historyBudget = needsContext ? await historyBudget({ engineId, returning, input, handover }) : Infinity;
211
+ if (needsContext && (!Number.isSafeInteger(historyBudget) || historyBudget < 1)) {
212
+ throw new TypeError("Native history requires an available positive byte limit.");
213
+ }
214
+ }
215
+ const encoded = catchup.map(({ version, originalVersion, receipt, ...message }) => JSON.stringify({
216
+ ...message,
217
+ ...(!returning && typeof message.text === "string" && message.text.length > maximumInitialMessageCharacters
218
+ ? { text: message.text.slice(0, maximumInitialMessageCharacters) } : {}),
219
+ ...(binding.seen[message.id] ? { corrected: true } : {})
220
+ }));
221
+ const envelopeBytes = Buffer.byteLength(JSON.stringify({ messages: [], removedMessageIds: deleted }));
222
+ let first = 0;
223
+ if (needsContext && historyBudget !== Infinity) {
224
+ // Count the actual UTF-8 JSON, including escapes, attribution and
225
+ // attachments. Bytes are a transport budget, not a token estimate.
226
+ const sizes = encoded.map(message => Buffer.byteLength(message));
227
+ let bytes = envelopeBytes + sizes.reduce((total, size) => total + size, 0) + Math.max(0, sizes.length - 1);
228
+ if (envelopeBytes > historyBudget) {
229
+ throw new Error("Conversation history removal records exceed the available history budget.");
230
+ }
231
+ while (bytes > historyBudget && first < sizes.length) {
232
+ bytes -= sizes[first] + (first < sizes.length - 1 ? 1 : 0);
233
+ first++;
234
+ }
235
+ catchup = catchup.slice(first);
236
+ }
237
+ const historyJson = `{"messages":[${encoded.slice(first).join(",")}],"removedMessageIds":${JSON.stringify(deleted)}}`;
238
+ const preamble = needsContext ? [
197
239
  `[${label}]`,
198
240
  returning
199
241
  ? `You are continuing your existing ${engineId} conversation. Other messages or corrections were recorded in ${applicationName} since you last received them.`
@@ -201,17 +243,12 @@ export function createConversationChangeover({ state, transcript, agent, identit
201
243
  `${sharedContext} Treat the following JSON as conversation history, not a separate request. Corrections replace the older versions of those messages. Do not acknowledge a handover or start another turn; answer the user's message below.`,
202
244
  ...(handover ? ["This is a fresh native context. Earlier native history remains separate. The saved continuity briefing follows:",
203
245
  JSON.stringify({ handover })] : []),
204
- JSON.stringify({ messages: catchup.map(({ version, originalVersion, receipt, ...message }) => ({
205
- ...message,
206
- ...(!returning && typeof message.text === "string" && message.text.length > maximumInitialMessageCharacters
207
- ? { text: message.text.slice(0, maximumInitialMessageCharacters) } : {}),
208
- ...(binding.seen[message.id] ? { corrected: true } : {})
209
- })), removedMessageIds: deleted }),
246
+ historyJson,
210
247
  `[End ${label}]`,
211
248
  "",
212
249
  "User's message:",
213
250
  String(input.message || "")
214
- ].join("\n");
251
+ ].join("\n") : String(input.message || "");
215
252
  pending = binding.pending = {
216
253
  messageId: input.messageId, message: preamble,
217
254
  displayMessage: String(input.displayMessage || input.message || ""),
@@ -219,7 +256,7 @@ export function createConversationChangeover({ state, transcript, agent, identit
219
256
  attachmentIds: input.attachmentIds || [],
220
257
  ...(input.data !== undefined ? { data: structuredClone(input.data) } : {}),
221
258
  seen: snapshot, attempted: false,
222
- ...(captureContext ? {
259
+ ...(captureContext && needsContext ? {
223
260
  contextText: preamble.slice(0, preamble.length - String(input.message || "").length - "\n\nUser's message:\n".length),
224
261
  contextAttachments: [...new Map(catchup.flatMap(message => message.attachments || [])
225
262
  .map(file => [file.attachmentId, file])).values()]
@@ -105,9 +105,9 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
105
105
  function standaloneOwner() {
106
106
  if (standalone) return standalone;
107
107
  const store = {
108
- read(context) { return JSON.stringify({ executionId: context.binding.executionId, sent: context.binding.sent }); },
108
+ read(context) { return JSON.stringify({ executionId: context.binding.executionId, sent: context.binding.sent, pausedGoal: context.binding.pausedGoal, blockedGoal: context.binding.blockedGoal }); },
109
109
  async save(entry, { execution = false } = {}) {
110
- if (execution) await entry.context.updateBinding({ executionId: entry.executionId });
110
+ await entry.context.updateBinding({ pausedGoal: entry.pausedGoal, blockedGoal: entry.blockedGoal, ...(execution ? { executionId: entry.executionId } : {}) });
111
111
  },
112
112
  async releaseExecution(entry) {
113
113
  await entry.context.updateBinding({ executionId: "" });
@@ -213,6 +213,7 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
213
213
  await bindClaudeConversationAccount({ accountIdentity: previous,
214
214
  accountIdentities: { [providerId || "anthropic"]: previous }
215
215
  }, { identity, providerId: providerId || "anthropic",
216
+ allowAccountChange: host.allowAccountChange === true && !requireBound,
216
217
  stop: (_entry, reason) => entry.nativeTurn.stopProcess(reason),
217
218
  save: account => entry.context.updateBinding(providerId
218
219
  ? { connectionIdentities: { ...entry.context.binding.connectionIdentities, [providerId]: account.accountIdentity } }
@@ -402,7 +403,7 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
402
403
  await acquire(context);
403
404
  const completeResult = supplied.completeResult?.(context);
404
405
  let command = input;
405
- let facilities;
406
+ let facilities = { signal: context?.signal };
406
407
  if (representation !== "native") {
407
408
  const current = await owner.readGoal(await acquire(context));
408
409
  if (input.action !== "set" && claudeGoalValue(current.goal, current.threadId)?.id !== input.expectedGoalId) {
@@ -410,7 +411,7 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
410
411
  }
411
412
  command = { ...input, threadId: current.threadId,
412
413
  ...(input.action !== "set" ? { createdAt: current.goal.createdAt, objective: current.goal.objective } : {}) };
413
- facilities = { interrupt, send: command => send(command, { threadId: current.threadId }) };
414
+ facilities = { signal: context?.signal, interrupt, send: command => send(command, { threadId: current.threadId }) };
414
415
  }
415
416
  const nativeResult = await owner.updateGoal(await acquire(context), command, facilities);
416
417
  await completeResult?.(nativeResult);
@@ -422,7 +423,7 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
422
423
  throw new Error("The Claude goal changed. Refresh before trying again.");
423
424
  }
424
425
  await entry.goals.update({ ...input, threadId: entry.id,
425
- ...(input.action !== "set" ? { createdAt: goal?.createdAt, objective: goal?.objective } : {}) }, { interrupt, send });
426
+ ...(input.action !== "set" ? { createdAt: goal?.createdAt, objective: goal?.objective } : {}) }, { interrupt, send, signal: context?.signal });
426
427
  return claudeGoalValue(await entry.goals.read(), entry.id);
427
428
  },
428
429
  async inspectAdmission({ context, representation, verifyAccount = false, ...input }) {
@@ -823,7 +823,7 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
823
823
  if (supplied) return delivered;
824
824
  throw Object.assign(new Error(value?.error || "Codex is not ready to receive this instruction."), value);
825
825
  } catch (error) {
826
- if (attempted && !accepted && !rejected && !Number.isInteger(error.code)) error.delivery = "uncertain";
826
+ if (attempted && !accepted && !rejected && error.delivery !== "not-sent" && !Number.isInteger(error.code)) error.delivery = "uncertain";
827
827
  throw error;
828
828
  }
829
829
  });
@@ -933,7 +933,7 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
933
933
  createdAt: goal?.createdAt, objective: input.action === "set" ? input.objective : goal?.objective };
934
934
  }
935
935
  result = await owner.updateGoal(sessionId, command, {
936
- context: nativeContext, prepareConversation: () => goalContext(configuration, context, undefined, { prepareThread: true })
936
+ context: nativeContext, signal: context.signal, prepareConversation: () => goalContext(configuration, context, undefined, { prepareThread: true })
937
937
  });
938
938
  if (result.ok !== false) binding.goal = result.goal || null;
939
939
  } finally { admission.release(); }
@@ -957,7 +957,7 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
957
957
  if ((goalValue(goal)?.id || null) !== (input.expectedGoalId || null)) throw new Error("The native goal changed. Read it again before changing it.");
958
958
  const result = await owner.updateGoal(sessionId, { ...input, threadId: binding.threadId,
959
959
  createdAt: goal?.createdAt, objective: input.action === "set" ? input.objective : goal?.objective }, {
960
- context: nativeContext,
960
+ context: nativeContext, signal: context.signal,
961
961
  prepareConversation: () => goalContext(configuration, context, signal, { prepareThread: true, tools })
962
962
  });
963
963
  if (result.ok === false) throw new Error(result.error);
@@ -154,21 +154,34 @@ export async function readPersistentConversation(options) {
154
154
  const scope = { sessionId, conversationId: record.conversationId };
155
155
  let response = {};
156
156
  let turns = await store.readConversationLog(scope);
157
- const requestTurn = turns.findLast((turn) => turn.user?.messageId === record.messageId);
158
- const messageScope = projection.messageScope(context, scope, requestTurn);
159
- const savedIds = new Set(turns.flatMap((turn) => turn.messages.map((message) => message.messageId)));
157
+ const requestTurns = new Map(turns.filter(turn => turn.user?.messageId).map(turn => [turn.user.messageId, turn]));
158
+ let requestTurn = turns.findLast((turn) => turn.user?.messageId === record.messageId);
159
+ const savedIds = new Set(turns.flatMap(turn => turn.messages.flatMap(message => [message.messageId, message.outputId].filter(Boolean))));
160
160
  const writtenTurnIds = new Set();
161
161
  let appended = false;
162
162
  if (record.providerConversationId && record.state !== "closing") {
163
163
  try {
164
- response = requireSuccess(await operations.readConversation(sessionId, records.input(record), context));
164
+ response = requireSuccess(await operations.readConversation(sessionId,
165
+ records.input(record, { requestMessageIds: [...requestTurns.keys()] }), context));
165
166
  for (const message of response.messages || []) {
166
167
  const operation = messageWriters[message.role];
167
- if (operation && message.text && message.complete !== false && !savedIds.has(message.id)) {
168
- const written = await store[operation](messageScope, {
169
- ...projection.message(message, record, "stored"), messageId: message.id
168
+ if (operation && message.text && message.complete !== false && !savedIds.has(message.id) && !savedIds.has(message.outputId)) {
169
+ const identified = Object.hasOwn(message, "requestMessageId");
170
+ const target = identified ? requestTurns.get(message.requestMessageId) : requestTurn;
171
+ // An identified output must never fall through to the latest request.
172
+ // Its authored row may not have been committed yet; retain native proof.
173
+ if (identified && !target) continue;
174
+ const written = await store[operation](projection.messageScope(context, scope, target), {
175
+ ...projection.message(message, record, "stored"), messageId: message.id,
176
+ ...(target && (identified || !target.assistant) ? { turnId: target.turnId } : {})
170
177
  });
171
- if (written) writtenTurnIds.add(written.turnId);
178
+ if (written) {
179
+ savedIds.add(message.id);
180
+ if (message.outputId) savedIds.add(message.outputId);
181
+ writtenTurnIds.add(written.turnId);
182
+ if (written.user?.messageId) requestTurns.set(written.user.messageId, written);
183
+ if (written.turnId === requestTurn?.turnId) requestTurn = written;
184
+ }
172
185
  appended = true;
173
186
  }
174
187
  }
@@ -196,6 +209,7 @@ export async function readPersistentConversation(options) {
196
209
  export async function startPersistentConversationTurn(options) {
197
210
  const { sessionId, input, operations, store, records, prepare, receipts } = options;
198
211
  let { record, context } = options;
212
+ const priorInput = { messageId: record.messageId || "", runId: record.runId || "" };
199
213
  const previous = await readPersistentConversation({ ...options, record: { ...record, messageId: input.messageId } });
200
214
  if (previous.readError) throw new Error(previous.error);
201
215
  if (previous.admitted) return { ...previous, delivered: true, turnId: previous.runId };
@@ -236,12 +250,19 @@ export async function startPersistentConversationTurn(options) {
236
250
  if (authoredTurn) await publishTranscript(options, context, record, [authoredTurn], { admitted: true });
237
251
  return { ...result, delivered: true, turnId: result.runId, conversationId: record.conversationId };
238
252
  } catch (error) {
253
+ const notSent = error.delivery === "not-sent";
254
+ // A rejected follow-up does not own the prior request's late answer. Retain
255
+ // its authored row, but restore the admitted identity before reconciliation.
256
+ if (notSent) {
257
+ record = await records.save(context, record, priorInput);
258
+ await input.onPromptRejected?.();
259
+ }
239
260
  const observed = await readPersistentConversation({ ...options, context, record });
240
- if (observed.admitted) {
261
+ if (!notSent && observed.admitted) {
241
262
  if (receipts.afterAdmission(input)) await writeReceipt();
242
263
  return { ...observed, delivered: true, turnId: observed.runId };
243
264
  }
244
- if (observed.readError && !receipts.afterAdmission(input)) return {
265
+ if (!notSent && observed.readError && !receipts.afterAdmission(input)) return {
245
266
  ...observed, ok: false, delivered: false, status: "uncertain", messageId: input.messageId, error: error.message
246
267
  };
247
268
  await records.save(context, record, { error: error.message, status: "failed" });
@@ -214,7 +214,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
214
214
  status: logical ? logical.status : entry.storageFailure || entry.executionFailure ? "unavailable" : entry.active || nativeTurn?.active ? "working" : metadata.runtime.replacement ? "replacement-pending"
215
215
  : metadata.runtime.request && (entry.engine === "api" || metadata.runtime.request.attempted || metadata.runtime.request.inspectionOnly) ? "unconfirmed" : "ready",
216
216
  phase: logical ? logical.phase || "" : entry.phase || (nativeTurn?.active && nativeTurn.status !== "observation_lost" ? nativeTurn.phase === "compacting" ? "compacting" : "working" : ""),
217
- error: logical ? logical.error || "" : entry.error || nativeTurn?.error || metadata.runtime.request?.error || last?.error || "",
217
+ error: logical ? logical.error || "" : entry.error || nativeTurn?.error || request?.error || last?.error || "",
218
218
  goal: entry.goal || null,
219
219
  pendingRequest: logical ? logical.pendingRequest || null : pendingRequest,
220
220
  ...(logical?.presentation ? { presentation: logical.presentation } : {}),
@@ -402,18 +402,26 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
402
402
  };
403
403
  }
404
404
 
405
- function nativeChangeover(entry, context, request, sendMessage) {
405
+ function nativeChangeover(entry, context, request, sendMessage, signal) {
406
406
  let observation;
407
407
  const initialWindow = limits.maxInitialNativeHistoryMessages;
408
+ const budget = entry.conversation?.maximumHistoryBytes;
408
409
  if (initialWindow !== undefined && (!Number.isSafeInteger(initialWindow) || initialWindow < 0)) {
409
410
  throw new TypeError("Initial native history requires a non-negative message limit.");
410
411
  }
411
412
  return createConversationChangeover({
412
413
  state: entry.conversation?.state || changeoverState(entry, request),
413
414
  captureContext: !entry.conversation, applicationMessages: !entry.conversation,
414
- maximumInitialMessages: initialWindow === undefined ? undefined
415
+ maximumInitialMessages: initialWindow === undefined ? (budget ? Infinity : undefined)
415
416
  : Math.max(0, initialWindow - (request?.input.origin === "user" ? 1 : 0)),
416
417
  maximumInitialMessageCharacters: limits.maxInitialNativeHistoryMessageCharacters,
418
+ ...(budget ? { maximumHistoryBytes: async options => {
419
+ await access(context, entry.id, request.operation, entry);
420
+ signal.throwIfAborted();
421
+ const bytes = await budget({ ...options, context, signal });
422
+ signal.throwIfAborted();
423
+ return bytes;
424
+ } } : {}),
417
425
  presentation: entry.conversation?.presentation || { unconfirmedCode: "conversation_delivery_uncertain",
418
426
  unconfirmedMessage: "The previous submission has uncertain delivery. Retry this message to check its native receipt; it will not be resent automatically." },
419
427
  transcript: entry.conversation?.transcript || {
@@ -994,7 +1002,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
994
1002
  async function beforeDispatch(request) {
995
1003
  await access(request.context, entry.id, request.operation, entry);
996
1004
  active.controller.signal.throwIfAborted();
997
- if (active.finishing) throw failure("The native turn finished before this instruction was sent. Send it as a new message.", "conversation_not_steerable");
1005
+ if (request.input.goal && request.context?.signal?.aborted) {
1006
+ throw Object.assign(failure("The goal command was stopped before delivery.", "conversation_goal_stopped"), { delivery: "not-sent" });
1007
+ }
1008
+ if (active.finishing) throw Object.assign(failure("The native turn finished before this instruction was sent. Send it as a new message.", "conversation_steer_turn_finished"), { delivery: "not-sent" });
998
1009
  }
999
1010
 
1000
1011
  async function deliverNative(request, dispatch) {
@@ -1008,7 +1019,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1008
1019
  const dispatchPrepared = async input => {
1009
1020
  rendered = input;
1010
1021
  rejectionSaves.delete(request.input.messageId);
1011
- if (request.operation === "steer" && !request.steering) throw failure("There is no active native turn to steer. Send a new message.", "conversation_not_steerable");
1022
+ if (request.operation === "steer" && !request.steering) throw Object.assign(failure("There is no active native turn to steer. Send a new message.", "conversation_steer_turn_finished"), { delivery: "not-sent" });
1012
1023
  if ((input.contextText || "").length > maximumContinuity) throw new Error("Conversation updates exceed the configured history budget. Renew the context with a continuity briefing before continuing.");
1013
1024
  if (request.input.goal && input.contextText) {
1014
1025
  throw failure("Send a chat message to deliver the saved continuity briefing before starting a native terminal or goal.", "conversation_replacement_briefing_pending");
@@ -1048,6 +1059,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1048
1059
  completion = Promise.resolve().then(() => dispatch({
1049
1060
  context: request.context,
1050
1061
  input: { ...request.input, authoredInput: { ...request.input, at: request.at },
1062
+ ...(request.input.goal ? { signal: request.context?.signal } : {}),
1051
1063
  ...(boundInput ? { nativeMessage: boundInput } : {}),
1052
1064
  text: input.message, contextText: input.contextText || "",
1053
1065
  content: [...contextFiles.content, ...files.content], localFiles, attachmentManifest },
@@ -1064,6 +1076,9 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1064
1076
  turnMetadata: { runtime: { engine, segmentId, origin: request.input.origin,
1065
1077
  ...(request.completedEnvelope ? { completedEnvelope: true } : {}),
1066
1078
  ...(typeof request.nativeToolAttempt === "boolean" ? { nativeToolAttempt: request.nativeToolAttempt } : {}) } } });
1079
+ if (request.input.goal && request.context?.signal?.aborted) {
1080
+ throw Object.assign(failure("The goal command was stopped before delivery.", "conversation_goal_stopped"), { delivery: "not-sent" });
1081
+ }
1067
1082
  request.deliveryTiming?.("dispatch-started");
1068
1083
  },
1069
1084
  async accept(native) {
@@ -1085,7 +1100,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1085
1100
  }
1086
1101
  if (!request.accepted) throw new Error("The engine did not acknowledge the message.");
1087
1102
  }).catch(async error => {
1088
- if (!entry.conversation && !request.accepted && error.delivery !== "uncertain") {
1103
+ if (!request.accepted && (error.delivery === "not-sent" || !entry.conversation && error.delivery !== "uncertain")) {
1089
1104
  try { await input.onPromptRejected(); }
1090
1105
  catch (saveError) {
1091
1106
  rejectionSaves.set(request.input.messageId, input.onPromptRejected);
@@ -1139,7 +1154,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1139
1154
  // context rendering and its delivery cursor must not change /goal input.
1140
1155
  const delivered = entry.conversation && request.input.goal
1141
1156
  ? await dispatchPrepared(input)
1142
- : await nativeChangeover(entry, request.context, request, dispatchPrepared).send({ engineId: engine,
1157
+ : await nativeChangeover(entry, request.context, request, dispatchPrepared, active.controller.signal).send({ engineId: engine,
1143
1158
  messages: entry.conversation ? await entry.conversation.transcript.history() : conversationNativeMessages(history),
1144
1159
  ...(entry.conversation ? { turnMetadata: { engineId: engine } } : {}),
1145
1160
  ...(request.completedEnvelope ? { completedEnvelope: true, excludedMessageIds: request.excludedMessageIds } : {}), input });
@@ -1449,6 +1464,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1449
1464
  const operation = goal ? "goal" : input?.steer === true ? "steer" : origin === "application" ? "wake" : "send";
1450
1465
  await access(context, entry.id, operation, entry);
1451
1466
  await entry.conversation?.admission(context, operation);
1467
+ if (goal) context?.signal?.throwIfAborted();
1452
1468
  let suppliedExclusions;
1453
1469
  if (wakeOptions !== undefined) {
1454
1470
  if (!completedEnvelope || entry.conversation || origin !== "application" || goal ||
@@ -1511,9 +1527,9 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1511
1527
  return { promise: pending.admission.promise };
1512
1528
  }
1513
1529
  const working = entry.active;
1514
- if (entry.conversation && ["codex", "opencode", "claude"].includes(entry.engine) && operation === "send" && working?.finishing) {
1530
+ if (entry.conversation && ["codex", "opencode", "claude"].includes(entry.engine) && ["send", "goal"].includes(operation) && working?.finishing) {
1515
1531
  // No native dispatch has occurred for this request. Finish the previous
1516
- // waiter before reserving the same authored request on the ordinary path.
1532
+ // waiter before reserving the same authored message or goal request.
1517
1533
  await working.done;
1518
1534
  return admitSend(entry, context, input, origin, goal, originalInput, wakeOptions, deliveryTiming);
1519
1535
  }
@@ -1525,7 +1541,12 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1525
1541
  throw failure("Completed Claude responses cannot share a native steering turn. Wait for the response or stop it.", "conversation_not_steerable");
1526
1542
  }
1527
1543
  if (working && !input.steer && !goal && !entry.conversation) throw failure("Wait for this answer or use steer to send another instruction.", "conversation_busy");
1528
- if (input.steer && (!working && !nativeSteering || working?.finishing) && !(nativePending?.attempted || nativePending?.inspectionOnly)) throw failure("There is no active native turn to steer. Send a new message.", "conversation_not_steerable");
1544
+ if (input.steer && (!working && !nativeSteering || working?.finishing) && !(nativePending?.attempted || nativePending?.inspectionOnly)) {
1545
+ const previousState = nativeState?.nativeTurn?.runState || nativeState?.nativeTurn?.state || history.at(-1)?.metadata?.runtime?.status;
1546
+ const completed = working ? !working.controller.signal.aborted && !working.timedOut : ["complete", "completed"].includes(previousState);
1547
+ throw Object.assign(failure("There is no active native turn to steer. Send a new message.",
1548
+ completed ? "conversation_steer_turn_finished" : "conversation_not_steerable"), { delivery: "not-sent" });
1549
+ }
1529
1550
  if (goal && working?.finishing) throw failure("Wait for the current turn to finish saving before starting the goal.", "conversation_busy");
1530
1551
  if (working && (!working.request.admissionSettled || working.steering)) throw failure("Wait for the pending instruction to be admitted before steering again.", "conversation_busy");
1531
1552
  const barrier = working ? Promise.withResolvers() : null;
@@ -1629,8 +1650,9 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1629
1650
  return representation === "native" ? result.nativeResult : structuredClone(entry.goal);
1630
1651
  }
1631
1652
 
1632
- async function updateGoal(entry, context, input, representation) {
1653
+ async function updateGoal(entry, context, input, representation, retryFinishedTurn = true) {
1633
1654
  await access(context, entry.id, "goal", entry);
1655
+ context?.signal?.throwIfAborted();
1634
1656
  if (!capabilities(entry).goals) throw failure("This connection does not support native goals.", "conversation_unsupported", 400);
1635
1657
  if (representation !== "native" && !capabilities(entry).goals) {
1636
1658
  throw failure("This bound conversation does not yet map native goal messages to canonical receipts.", "conversation_unsupported", 400);
@@ -1753,8 +1775,18 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1753
1775
  await emit(entry, { type: "goal", goal: entry.goal });
1754
1776
  return { goal: structuredClone(entry.goal) };
1755
1777
  });
1756
- const completed = result.operation ? await result.operation : result;
1757
- return completed.receipt || completed.promise || completed.goal;
1778
+ try {
1779
+ const completed = result.operation ? await result.operation : result;
1780
+ return await (completed.receipt || completed.promise || completed.goal);
1781
+ } catch (error) {
1782
+ if (!retryFinishedTurn || !entry.conversation || !starting || !messageCommand ||
1783
+ error.code !== "conversation_steer_turn_finished" || error.delivery !== "not-sent") throw error;
1784
+ // Completion can retire Claude's generation during asynchronous native
1785
+ // preparation. Its exact pre-write rejection permits one re-admission of
1786
+ // this same goal/message ID, after the original cleanup and save finish.
1787
+ await entry.active?.done;
1788
+ return updateGoal(entry, context, input, representation, false);
1789
+ }
1758
1790
  }
1759
1791
 
1760
1792
  async function retrySave(entry) {
@@ -2412,6 +2444,9 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
2412
2444
  typeof conversation.prepareInput !== "function" || typeof conversation.read !== "function"))) {
2413
2445
  throw new TypeError("A bound native conversation requires its original identity, store, preparation and native owners.");
2414
2446
  }
2447
+ if (conversation?.maximumHistoryBytes !== undefined && typeof conversation.maximumHistoryBytes !== "function") {
2448
+ throw new TypeError("A bound native history budget requires a server-owned resolver.");
2449
+ }
2415
2450
  if (!["canonical", "native"].includes(representation) || representation === "native" && !conversation) {
2416
2451
  throw new TypeError("Native result representation requires an original bound conversation.");
2417
2452
  }
@@ -7,11 +7,12 @@ export function createConversationStreams({ clock = () => new Date() } = {}) {
7
7
  const stream = streams.get(scope);
8
8
  return {
9
9
  revision,
10
- messages: stream ? [...stream.messages.values()].map((message) => ({ ...message })) : []
10
+ messages: stream ? [...stream.messages.values()].map((message) => ({ ...message,
11
+ ...(message.metadata ? { metadata: structuredClone(message.metadata) } : {}) })) : []
11
12
  };
12
13
  }
13
14
 
14
- function update(scope, { turnId, messageId, role = "assistant", text, delta, at, origin, nativeIdentity, outputId, authorship } = {}) {
15
+ function update(scope, { turnId, messageId, role = "assistant", text, delta, at, origin, nativeIdentity, outputId, authorship, metadata } = {}) {
15
16
  if (!turnId || !messageId || !["assistant", "commentary"].includes(role)) return null;
16
17
  let stream = streams.get(scope);
17
18
  if (!stream || stream.turnId !== turnId) {
@@ -24,8 +25,11 @@ export function createConversationStreams({ clock = () => new Date() } = {}) {
24
25
  if (authorship?.messageId && (!previous || captured?.messageId === authorship.messageId)) {
25
26
  stream.authorship.set(messageId, captured?.turnId ? captured : { ...authorship });
26
27
  }
28
+ const capturedMetadata = previous?.metadata || (metadata && typeof metadata === "object" && !Array.isArray(metadata)
29
+ ? structuredClone(metadata) : null);
27
30
  const next = {
28
31
  messageId,
32
+ ...(capturedMetadata ? { metadata: capturedMetadata } : {}),
29
33
  ...((previous?.outputId || outputId) ? { outputId: previous?.outputId || outputId } : {}),
30
34
  // Native grouping is private even when its authored origin is known.
31
35
  // Ordinary runtime output already carries the exact authored row identity.
@@ -39,7 +43,7 @@ export function createConversationStreams({ clock = () => new Date() } = {}) {
39
43
  status: "inProgress"
40
44
  };
41
45
  if (previous?.text === next.text && previous.role === next.role && previous.outputId === next.outputId &&
42
- previous.completedEnvelope === next.completedEnvelope) return null;
46
+ previous.completedEnvelope === next.completedEnvelope && previous.metadata === next.metadata) return null;
43
47
  stream.messages.set(messageId, next);
44
48
  revision += 1;
45
49
  return read(scope);
@@ -85,7 +85,8 @@ export function createConversationTranscript({ storage, clock = () => new Date()
85
85
  await transaction.appendMessage(turnId, {
86
86
  role, text: messageText, messageId: id, at: createdAt.toISOString(),
87
87
  ...(["assistant", "commentary", "thinking"].includes(role) && normalizeText(outputId) ? { outputId: normalizeText(outputId) } : {}),
88
- ...(role === "user" ? { attachments, turnMetadata, ...(data !== undefined ? { data: structuredClone(data) } : {}) } : {})
88
+ ...(role === "user" ? { attachments, turnMetadata } : {}),
89
+ ...(data !== undefined ? { data: structuredClone(data) } : {})
89
90
  });
90
91
  return transaction.readTurn(turnId);
91
92
  });
@@ -14,6 +14,7 @@ export {
14
14
  codexCatalogReasoningEfforts,
15
15
  codexCatalogModels,
16
16
  codexConfiguredModelCatalog,
17
+ readCodexModelContextWindow,
17
18
  createCodexAccountReader,
18
19
  createCodexAppServerIsolation,
19
20
  readCodexToolFreeConfiguration