@jskit-ai/assistant-core 0.1.226 → 0.1.227
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/examples/conversation/package.json +1 -1
- package/fixtures/responsive-assistant/ConversationFixture.vue +5 -3
- package/fixtures/responsive-assistant/DeliveryFixture.vue +9 -0
- package/package.json +1 -1
- package/src/client/conversation/AssistantConversationElement.vue +7 -4
- package/src/client/conversation/AssistantGoalControl.vue +30 -16
- package/src/client/conversation/AssistantTranscript.vue +14 -7
- package/src/client/conversation/useAssistantAttachments.js +6 -3
- package/src/server/conversation/claudeClient.js +22 -5
- package/src/server/conversation/claudeHistory.js +62 -5
- package/src/server/conversation/claudeProcess.js +7 -6
- package/src/server/conversation/claudeTurn.js +284 -57
- package/src/server/conversation/codexConfiguration.js +24 -1
- package/src/server/conversation/codexGoalCommands.js +8 -3
- package/src/server/conversation/codexTurnRecovery.js +18 -8
- package/src/server/conversation/continuity.js +54 -17
- package/src/server/conversation/providers/claudeDriver.js +6 -5
- package/src/server/conversation/providers/codexDriver.js +3 -3
- package/src/server/conversation/providers/scoped.js +31 -10
- package/src/server/conversation/runtime.js +48 -13
- package/src/server/conversation/streams.js +7 -3
- package/src/server/conversation/transcript.js +2 -1
- package/src/server/hosts/codexConfiguration.js +1 -0
- package/src/shared/conversation/longTextBlocks.js +0 -4
- package/test/assistantScroll.browser.test.js +74 -3
- package/test/claudeClient.test.js +73 -0
- package/test/claudeConversationSession.test.js +120 -4
- package/test/claudeProcess.test.js +61 -0
- package/test/claudeTurn.test.js +1315 -10
- package/test/codexConversationSession.test.js +9 -1
- package/test/codexGoal.test.js +47 -0
- package/test/codexModelCatalog.test.js +55 -0
- package/test/codexTurn.test.js +228 -0
- package/test/conversationAttachments.test.js +17 -0
- package/test/conversationRuntime.test.js +40 -0
- package/test/conversationStreaming.test.js +18 -0
- 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
|
-
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
const savedIds = new Set(turns.flatMap(
|
|
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,
|
|
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
|
|
169
|
-
|
|
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)
|
|
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 ||
|
|
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 (
|
|
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.", "
|
|
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 (!
|
|
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) &&
|
|
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
|
|
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))
|
|
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
|
-
|
|
1757
|
-
|
|
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
|
|
88
|
+
...(role === "user" ? { attachments, turnMetadata } : {}),
|
|
89
|
+
...(data !== undefined ? { data: structuredClone(data) } : {})
|
|
89
90
|
});
|
|
90
91
|
return transaction.readTurn(turnId);
|
|
91
92
|
});
|