@jskit-ai/assistant-core 0.1.231 → 0.1.233
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/package.json +1 -1
- package/src/server/conversation/claudeHistory.js +86 -8
- package/src/server/conversation/claudeTurn.js +17 -6
- package/src/server/conversation/deliveryTiming.js +7 -2
- package/src/server/conversation/fileStorage.js +12 -1
- package/src/server/conversation/runtime.js +59 -18
- package/src/server/conversation/storage.js +121 -51
- package/src/server/conversation/transcript.js +21 -0
- package/test/claudeConversationSession.test.js +23 -0
- package/test/claudeTurn.test.js +55 -3
- package/test/codexConversationSession.test.js +4 -2
- package/test/fileConversationStorage.test.js +31 -0
- package/test/opencodeConversationSession.test.js +4 -2
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createReadStream } from "node:fs";
|
|
2
|
-
import { lstat, readdir, realpath, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { lstat, open, readdir, realpath, rm, stat } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { readClaudeJsonFrames } from "./claudeClient.js";
|
|
5
5
|
import { createNativeHistoryExport, retireNativeConversation } from "./nativeHistoryExport.js";
|
|
@@ -63,7 +63,7 @@ async function claudeHistoryPath({ configRoot, workdir, conversationId }) {
|
|
|
63
63
|
if (error.code === "ENOENT") return null;
|
|
64
64
|
throw error;
|
|
65
65
|
});
|
|
66
|
-
if (info?.isFile()) return { path: candidate, size: info.size };
|
|
66
|
+
if (info?.isFile()) return { path: candidate, size: info.size, dev: info.dev, ino: info.ino };
|
|
67
67
|
}
|
|
68
68
|
return null;
|
|
69
69
|
}
|
|
@@ -95,6 +95,82 @@ function claudeMessageBlocks(frame, { includeNested = false, commentary = false
|
|
|
95
95
|
});
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// Claude appends to its native history. Live reads keep the parsed complete
|
|
99
|
+
// frames per file and parse only bytes appended since the previous read. A
|
|
100
|
+
// replaced, truncated or rewritten file is parsed again from the start.
|
|
101
|
+
const HISTORY_CACHE_LIMIT = 8;
|
|
102
|
+
const HISTORY_CHECK_BYTES = 256;
|
|
103
|
+
const historyCache = new Map();
|
|
104
|
+
|
|
105
|
+
// Retain only the fields the history projection reads. Tool results and tool
|
|
106
|
+
// inputs can be large and are never part of the projection.
|
|
107
|
+
function retainedClaudeFrame(frame) {
|
|
108
|
+
if (!["user", "assistant", "system", "attachment", "result", "last-prompt"].includes(frame.type)) {
|
|
109
|
+
return { type: frame.type, uuid: frame.uuid, parentUuid: frame.parentUuid };
|
|
110
|
+
}
|
|
111
|
+
const content = frame.message?.content;
|
|
112
|
+
if (!Array.isArray(content) || !["user", "assistant"].includes(frame.type)) {
|
|
113
|
+
if (frame.type !== "user" || frame.toolUseResult === undefined) return frame;
|
|
114
|
+
const { toolUseResult: _result, ...retained } = frame;
|
|
115
|
+
return retained;
|
|
116
|
+
}
|
|
117
|
+
const blocks = frame.type === "user"
|
|
118
|
+
? content.filter(block => block?.type === "text")
|
|
119
|
+
: content.map(block => block?.type === "tool_use" && block.name !== "StructuredOutput"
|
|
120
|
+
? { type: "tool_use", id: block.id, name: block.name } : block);
|
|
121
|
+
const { toolUseResult: _result, ...retained } = frame;
|
|
122
|
+
return { ...retained, message: { ...frame.message, content: blocks } };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function claudeHistoryFrames(file) {
|
|
126
|
+
// Reads of one file are serialized so each extends the same parsed prefix.
|
|
127
|
+
const previous = historyCache.get(file.path);
|
|
128
|
+
const reading = Promise.resolve(previous?.reading).catch(() => {}).then(() => readClaudeHistoryFrames(file));
|
|
129
|
+
historyCache.delete(file.path);
|
|
130
|
+
historyCache.set(file.path, { ...previous?.value && { value: previous.value }, reading });
|
|
131
|
+
while (historyCache.size > HISTORY_CACHE_LIMIT) historyCache.delete(historyCache.keys().next().value);
|
|
132
|
+
return reading;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function readClaudeHistoryFrames(file) {
|
|
136
|
+
const cached = historyCache.get(file.path)?.value;
|
|
137
|
+
const handle = await open(file.path, "r");
|
|
138
|
+
try {
|
|
139
|
+
let reusable = cached && cached.dev === file.dev && cached.ino === file.ino && cached.offset <= file.size;
|
|
140
|
+
if (reusable) {
|
|
141
|
+
const check = Buffer.alloc(cached.check.length);
|
|
142
|
+
await handle.read(check, 0, check.length, cached.offset - check.length);
|
|
143
|
+
reusable = check.equals(cached.check);
|
|
144
|
+
}
|
|
145
|
+
const start = reusable ? cached.offset : 0;
|
|
146
|
+
const appended = Buffer.alloc(file.size - start);
|
|
147
|
+
if (appended.length) await handle.read(appended, 0, appended.length, start);
|
|
148
|
+
// Only newline-terminated frames are complete; a writer may still be
|
|
149
|
+
// writing the unfinished tail, which the next read parses.
|
|
150
|
+
const complete = appended.lastIndexOf(10) + 1;
|
|
151
|
+
const added = [];
|
|
152
|
+
if (complete) {
|
|
153
|
+
for await (const frame of readClaudeJsonFrames([appended.subarray(0, complete)])) {
|
|
154
|
+
if (!frame.isSidechain) added.push(retainedClaudeFrame(frame));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const offset = start + complete;
|
|
158
|
+
const check = Buffer.alloc(Math.min(HISTORY_CHECK_BYTES, offset));
|
|
159
|
+
await handle.read(check, 0, check.length, offset - check.length);
|
|
160
|
+
const value = { dev: file.dev, ino: file.ino, offset, check,
|
|
161
|
+
frames: reusable ? cached.frames.concat(added) : added };
|
|
162
|
+
const current = historyCache.get(file.path);
|
|
163
|
+
if (current) current.value = value;
|
|
164
|
+
return value.frames;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
const current = historyCache.get(file.path);
|
|
167
|
+
if (current) delete current.value;
|
|
168
|
+
throw error;
|
|
169
|
+
} finally {
|
|
170
|
+
await handle.close();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
98
174
|
async function readClaudeHistory(options) {
|
|
99
175
|
const file = await claudeHistoryPath(options);
|
|
100
176
|
const inspected = {
|
|
@@ -110,18 +186,20 @@ async function readClaudeHistory(options) {
|
|
|
110
186
|
let goal = null;
|
|
111
187
|
let goalStartId = "";
|
|
112
188
|
let goalCommandId = "";
|
|
113
|
-
|
|
189
|
+
let frames = [];
|
|
114
190
|
const byId = new Map();
|
|
115
191
|
let leafUuid = "";
|
|
116
192
|
let rewound = false;
|
|
117
193
|
// Snapshot the file length. A writer can be in the middle of its last frame;
|
|
118
194
|
// Live history reads allow that unfinished tail. Stopped maintenance can
|
|
119
195
|
// require the existing strict parser without changing live observation.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
196
|
+
if (options.allowIncompleteTail ?? true) frames = await claudeHistoryFrames(file);
|
|
197
|
+
else {
|
|
198
|
+
for await (const frame of readClaudeJsonFrames(createReadStream(file.path, { end: file.size - 1 }))) {
|
|
199
|
+
if (!frame.isSidechain) frames.push(frame);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const frame of frames) {
|
|
125
203
|
if (frame.uuid) byId.set(frame.uuid, frame);
|
|
126
204
|
if (["user", "assistant"].includes(frame.type) && frame.uuid) leafUuid = frame.uuid;
|
|
127
205
|
if (frame.type === "last-prompt" && frame.leafUuid) {
|
|
@@ -561,12 +561,15 @@ export function createClaudeConversationOwner({
|
|
|
561
561
|
const context = entry.context;
|
|
562
562
|
const messageId = command ? input.messageId : text(input.messageId) || randomUUID();
|
|
563
563
|
const uuid = claudeNativeMessageId(messageId);
|
|
564
|
+
const deliveryTiming = (command || input).deliveryTiming;
|
|
564
565
|
if (!command) {
|
|
565
566
|
if (entry.main && await store.hasMessage(context, messageId)) {
|
|
566
567
|
return { ok: true, delivered: true, duplicate: true, thread: { id: entry.id }, turn: snapshot(entry) };
|
|
567
568
|
}
|
|
569
|
+
deliveryTiming?.("duplicate-checked");
|
|
568
570
|
preparation.check(entry, context);
|
|
569
571
|
const history = await readHistory(entry);
|
|
572
|
+
deliveryTiming?.("native-history-read");
|
|
570
573
|
if (history.userIds.includes(uuid)) {
|
|
571
574
|
return { ok: true, delivered: true, duplicate: true, thread: { id: entry.id }, turn: snapshot(entry) };
|
|
572
575
|
}
|
|
@@ -587,6 +590,7 @@ export function createClaudeConversationOwner({
|
|
|
587
590
|
// authored input. Keep its failure distinct from a lost native receipt.
|
|
588
591
|
throw Object.assign(error, { delivery: "not-sent" });
|
|
589
592
|
}
|
|
593
|
+
deliveryTiming?.("process-ready");
|
|
590
594
|
if (command && !command.steering) command.signal.throwIfAborted();
|
|
591
595
|
const steering = command ? command.steering === true : Boolean(entry.turn?.active);
|
|
592
596
|
const sendNow = steering && native.client.supportsSendNow?.() === true;
|
|
@@ -607,7 +611,8 @@ export function createClaudeConversationOwner({
|
|
|
607
611
|
throw Object.assign(error, { delivery: "not-sent" });
|
|
608
612
|
}
|
|
609
613
|
}
|
|
610
|
-
const prompt = command ? undefined : await preparation.message(entry, input, { context, message, steering, renewal });
|
|
614
|
+
const prompt = command ? undefined : await preparation.message(entry, input, { context, message, steering, renewal, deliveryTiming });
|
|
615
|
+
if (!command) deliveryTiming?.("prompt-rendered");
|
|
611
616
|
if (command) {
|
|
612
617
|
await command.beforeDispatch({ threadId: entry.id });
|
|
613
618
|
command.signal.throwIfAborted();
|
|
@@ -623,6 +628,7 @@ export function createClaudeConversationOwner({
|
|
|
623
628
|
entry.onEvent = context.onEvent;
|
|
624
629
|
entry.lastMessageId = uuid;
|
|
625
630
|
const actorMetadata = command ? undefined : await onEvent(entry, { type: "message-metadata", input, context });
|
|
631
|
+
if (!command) deliveryTiming?.("metadata-ready");
|
|
626
632
|
const nativeOwner = applicationCommand ? { process: native, executionId: entry.executionId,
|
|
627
633
|
accountIdentity: entry.accountIdentity, threadId: entry.id, turnId: entry.turn.id } : null;
|
|
628
634
|
let conversationTurn;
|
|
@@ -646,8 +652,12 @@ export function createClaudeConversationOwner({
|
|
|
646
652
|
} else {
|
|
647
653
|
const admission = onEvent(entry, { type: "admit-message", input, context, renewal, message, messageId, uuid, actorMetadata });
|
|
648
654
|
if (admission) conversationTurn = await admission;
|
|
655
|
+
deliveryTiming?.("receipt-written");
|
|
656
|
+
}
|
|
657
|
+
if (!command) {
|
|
658
|
+
await entry.nativeTurn.updateState("active");
|
|
659
|
+
deliveryTiming?.("state-active");
|
|
649
660
|
}
|
|
650
|
-
if (!command) await entry.nativeTurn.updateState("active");
|
|
651
661
|
(command || input).onReceiptPersisted?.();
|
|
652
662
|
if (applicationCommand && conversationTurn) {
|
|
653
663
|
const value = deliveryResult();
|
|
@@ -982,7 +992,8 @@ export function createClaudeConversationOwner({
|
|
|
982
992
|
entry.toolSchemas = active.tools?.schemas || [];
|
|
983
993
|
}
|
|
984
994
|
const value = await send(entry, { ...command.input.nativeMessage, signal: command.input.signal, onPromptSending: command.beforeDispatch,
|
|
985
|
-
onNativeReceipt: command.onNativeReceipt, onReceiptPersisted: command.onReceiptPersisted
|
|
995
|
+
onNativeReceipt: command.onNativeReceipt, onReceiptPersisted: command.onReceiptPersisted, deliveryTiming: command.deliveryTiming },
|
|
996
|
+
{ applicationCommand });
|
|
986
997
|
const completion = value.conversationTurn ? entry.completion?.promise : null;
|
|
987
998
|
const delivered = { value, completion };
|
|
988
999
|
if (!applicationCommand && value.delivered === true && value.conversationTurn) {
|
|
@@ -1012,9 +1023,9 @@ export function createClaudeConversationOwner({
|
|
|
1012
1023
|
control.current = active;
|
|
1013
1024
|
const ready = Promise.withResolvers();
|
|
1014
1025
|
ready.promise.catch(() => {});
|
|
1015
|
-
active.steer = async ({ input, beforeDispatch, accept, onNativeReceipt, onReceiptPersisted }) => {
|
|
1026
|
+
active.steer = async ({ input, beforeDispatch, accept, onNativeReceipt, onReceiptPersisted, deliveryTiming }) => {
|
|
1016
1027
|
await ready.promise;
|
|
1017
|
-
const nativeDispatch = { active, signal, beforeDispatch, accept, onNativeReceipt, onReceiptPersisted,
|
|
1028
|
+
const nativeDispatch = { active, signal, beforeDispatch, accept, onNativeReceipt, onReceiptPersisted, deliveryTiming,
|
|
1018
1029
|
timeoutMs, steering: true, interrupted: false, attempted: false, admitted: false };
|
|
1019
1030
|
try { await dispatch(control, { input, context, signal }, { acquire, entry, dispatch: nativeDispatch }); }
|
|
1020
1031
|
catch (error) {
|
|
@@ -1024,7 +1035,7 @@ export function createClaudeConversationOwner({
|
|
|
1024
1035
|
}
|
|
1025
1036
|
};
|
|
1026
1037
|
const nativeDispatch = { active, signal, beforeDispatch, accept, onNativeReceipt: command.onNativeReceipt,
|
|
1027
|
-
onReceiptPersisted: command.onReceiptPersisted, timeoutMs, maximumOutput, attempted: false, admitted: false };
|
|
1038
|
+
onReceiptPersisted: command.onReceiptPersisted, deliveryTiming: command.deliveryTiming, timeoutMs, maximumOutput, attempted: false, admitted: false };
|
|
1028
1039
|
let entry;
|
|
1029
1040
|
let cancellation;
|
|
1030
1041
|
let problem;
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
// Finer marks within each phase locate slow work. Hosts may mark git-actor
|
|
2
|
+
// and genesis-rendered while preparing a native prompt.
|
|
1
3
|
const stages = new Set([
|
|
2
|
-
"received", "preparation-started", "
|
|
3
|
-
"
|
|
4
|
+
"received", "preparation-started", "authorized", "history-read", "state-read", "request-saved",
|
|
5
|
+
"input-prepared", "provider-started", "duplicate-checked", "native-history-read", "process-ready",
|
|
6
|
+
"git-actor", "genesis-rendered", "prompt-rendered", "metadata-ready", "runtime-ready", "dispatch-started",
|
|
7
|
+
"native-acknowledged", "receipt-written", "state-active", "receipt-persisted", "admission-authorized",
|
|
8
|
+
"turn-located", "accepted", "first-response", "failed"
|
|
4
9
|
]);
|
|
5
10
|
|
|
6
11
|
// Observer results are never awaited and failures cannot change delivery.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { createConversationStorage } from "./storage.js";
|
|
5
5
|
|
|
@@ -34,6 +34,17 @@ export function createFileConversationStorage({ directory } = {}) {
|
|
|
34
34
|
|
|
35
35
|
return createConversationStorage({
|
|
36
36
|
readRecord,
|
|
37
|
+
// Atomic replacement gives every save a new inode; size and modification
|
|
38
|
+
// time also change for an in-place external edit.
|
|
39
|
+
async recordVersion(id) {
|
|
40
|
+
try {
|
|
41
|
+
const info = await stat(filename(id), { bigint: true });
|
|
42
|
+
return `${info.dev}:${info.ino}:${info.size}:${info.mtimeNs}`;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error.code === "ENOENT") return "absent";
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
},
|
|
37
48
|
async writeRecord(id, { turns, metadata }) {
|
|
38
49
|
// Serialize before touching the filesystem: failed transactions cannot
|
|
39
50
|
// replace the last saved conversation or leave a partial document.
|
|
@@ -83,10 +83,15 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
83
83
|
throw new TypeError("Invalid conversation request time limit.");
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
// Send and its immediate admission share one authorization. Dispatch,
|
|
87
|
+
// receipt admission and every other operation authorize again.
|
|
88
|
+
async function access(context, id, operation, entry, grant) {
|
|
87
89
|
if (closed || entry?.disposed) throw failure("This conversation handle is closed.", "conversation_closed");
|
|
88
|
-
if (!
|
|
89
|
-
|
|
90
|
+
if (!grant?.has(operation)) {
|
|
91
|
+
if (!await authorize({ context, conversationId: id, operation })) {
|
|
92
|
+
throw failure("This conversation is not available to this identity.", "conversation_forbidden", 403);
|
|
93
|
+
}
|
|
94
|
+
grant?.add(operation);
|
|
90
95
|
}
|
|
91
96
|
if (closed || entry?.disposed) throw failure("This conversation handle is closed.", "conversation_closed");
|
|
92
97
|
}
|
|
@@ -146,6 +151,27 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
146
151
|
const readTranscript = entry => entry.conversation
|
|
147
152
|
? entry.conversation.transcript.readConversationLog() : transcript.readConversationLog(entry.id);
|
|
148
153
|
|
|
154
|
+
// Identity lookups avoid reading the whole log when the transcript supports
|
|
155
|
+
// them. A bound host may supply findSubmittedTurn(messageId) and readTurn(turnId).
|
|
156
|
+
async function findSubmittedTurn(entry, messageId) {
|
|
157
|
+
const find = entry.conversation ? entry.conversation.transcript.findSubmittedTurn : transcript.findSubmittedTurn &&
|
|
158
|
+
(id => transcript.findSubmittedTurn(entry.id, id));
|
|
159
|
+
if (typeof find === "function") return (await find(messageId)) || null;
|
|
160
|
+
return (await readTranscript(entry)).find(turn => submittedMessage(turn)?.messageId === messageId) || null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function readBoundTurn(entry, turnId) {
|
|
164
|
+
if (typeof entry.conversation.transcript.readTurn === "function") return (await entry.conversation.transcript.readTurn(turnId)) || null;
|
|
165
|
+
return (await readTranscript(entry)).find(turn => turn.turnId === turnId) || null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function lastTranscriptTurn(entry) {
|
|
169
|
+
const page = entry.conversation && typeof entry.conversation.transcript.readConversationLogPage !== "function" ? null
|
|
170
|
+
: entry.conversation ? await entry.conversation.transcript.readConversationLogPage({ limit: 1 })
|
|
171
|
+
: await transcript.readConversationLogPage(entry.id, { limit: 1 });
|
|
172
|
+
return page?.conversationLog.at(-1) || (await readTranscript(entry)).at(-1);
|
|
173
|
+
}
|
|
174
|
+
|
|
149
175
|
function readTranscriptPage(entry, query) {
|
|
150
176
|
if (!entry.conversation) return transcript.readConversationLogPage(entry.id, { ...query, presentation: completedEnvelope });
|
|
151
177
|
if (typeof entry.conversation.transcript.readConversationLogPage !== "function") {
|
|
@@ -418,7 +444,9 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
418
444
|
...(budget ? { maximumHistoryBytes: async options => {
|
|
419
445
|
await access(context, entry.id, request.operation, entry);
|
|
420
446
|
signal.throwIfAborted();
|
|
421
|
-
|
|
447
|
+
// A host may free native context first; show that as compacting.
|
|
448
|
+
const bytes = await budget({ ...options, context, signal,
|
|
449
|
+
setPhase: phase => setPhase(entry, phase === "compacting" ? "compacting" : "preparing") });
|
|
422
450
|
signal.throwIfAborted();
|
|
423
451
|
return bytes;
|
|
424
452
|
} } : {}),
|
|
@@ -825,13 +853,15 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
825
853
|
|
|
826
854
|
async function admit(request, seen, { nativeDelivery = false, conversationTurn } = {}) {
|
|
827
855
|
await access(request.context, entry.id, request.operation, entry);
|
|
856
|
+
request.deliveryTiming?.("admission-authorized");
|
|
828
857
|
if (!conversationTurn) active.controller.signal.throwIfAborted();
|
|
829
858
|
if (request.input.goal) request.goalMessageId = request.input.messageId;
|
|
830
859
|
else if (goalOwner) request.goalMessageId = goalOwner.input.messageId;
|
|
831
860
|
const nativeTurnId = active.nativeTurnId;
|
|
832
861
|
const previous = current;
|
|
833
862
|
if (entry.conversation) {
|
|
834
|
-
const saved =
|
|
863
|
+
const saved = conversationTurn?.turnId ? await readBoundTurn(entry, conversationTurn.turnId) : null;
|
|
864
|
+
request.deliveryTiming?.("turn-located");
|
|
835
865
|
if (submittedMessage(saved || {})?.messageId !== request.input.messageId) {
|
|
836
866
|
throw new Error("The native receipt does not belong to this authored request.");
|
|
837
867
|
}
|
|
@@ -1009,7 +1039,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1009
1039
|
}
|
|
1010
1040
|
|
|
1011
1041
|
async function deliverNative(request, dispatch) {
|
|
1012
|
-
|
|
1042
|
+
// A bound conversation supplies its own native history below.
|
|
1043
|
+
const history = entry.conversation ? [] : (await readTranscript(entry)).filter(turn => !turn.metadata?.runtime?.supersededBy);
|
|
1013
1044
|
let completion;
|
|
1014
1045
|
let committed;
|
|
1015
1046
|
let rendered;
|
|
@@ -1027,10 +1058,12 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1027
1058
|
const readAttachments = attachmentReader(request);
|
|
1028
1059
|
// The bound host has already resolved its original attachment records;
|
|
1029
1060
|
// the production preparation owner keeps their manifest/native mapping.
|
|
1030
|
-
const boundInput = entry.conversation
|
|
1061
|
+
const boundInput = entry.conversation
|
|
1062
|
+
? await entry.conversation.prepareInput(input, request.context, { deliveryTiming: request.deliveryTiming }) : null;
|
|
1031
1063
|
const files = boundInput ? { attachments: boundInput.displayAttachments || [], localFiles: [], content: [] }
|
|
1032
1064
|
: await readAttachments((input.attachmentIds || []).map(attachmentId => ({ attachmentId })));
|
|
1033
1065
|
const contextFiles = boundInput ? { localFiles: [], content: [] } : await readAttachments(input.contextAttachments || []);
|
|
1066
|
+
request.deliveryTiming?.("input-prepared");
|
|
1034
1067
|
const localFiles = [...contextFiles.localFiles, ...files.localFiles];
|
|
1035
1068
|
if (request.input.goal && localFiles.length) {
|
|
1036
1069
|
throw failure("Native goals require extracted text or supported image bytes for attachments.", "conversation_unsupported", 400);
|
|
@@ -1064,6 +1097,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1064
1097
|
text: input.message, contextText: input.contextText || "",
|
|
1065
1098
|
content: [...contextFiles.content, ...files.content], localFiles, attachmentManifest },
|
|
1066
1099
|
signal: active.controller.signal,
|
|
1100
|
+
deliveryTiming: request.deliveryTiming,
|
|
1067
1101
|
onNativeReceipt: () => request.deliveryTiming?.("native-acknowledged"),
|
|
1068
1102
|
onReceiptPersisted: () => request.deliveryTiming?.("receipt-persisted"),
|
|
1069
1103
|
...(engine === "opencode" ? { deliveryCommitted: committed.promise } : {}),
|
|
@@ -1168,7 +1202,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1168
1202
|
}
|
|
1169
1203
|
if (request.accepted) await publishAdmission(request, nativeResult);
|
|
1170
1204
|
else {
|
|
1171
|
-
const saved =
|
|
1205
|
+
const saved = await findSubmittedTurn(entry, request.input.messageId);
|
|
1172
1206
|
if (!saved && entry.conversation) {
|
|
1173
1207
|
// An original native-history duplicate need not have an authored
|
|
1174
1208
|
// application row. Preserve its exact result without inventing a
|
|
@@ -1224,7 +1258,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1224
1258
|
|
|
1225
1259
|
try {
|
|
1226
1260
|
await setPhase(entry, "preparing");
|
|
1227
|
-
|
|
1261
|
+
// Only the API engine sends the application transcript as history.
|
|
1262
|
+
const recordedHistory = engine === "api" ? await readTranscript(entry) : [];
|
|
1228
1263
|
const activeHistory = recordedHistory.filter(turn => !turn.metadata?.runtime?.supersededBy);
|
|
1229
1264
|
const metadata = { runtime: await readState(entry) };
|
|
1230
1265
|
const continuity = metadata.runtime.continuity;
|
|
@@ -1436,8 +1471,9 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1436
1471
|
const deliveryTiming = createConversationDeliveryTiming({ engine: entry.engine,
|
|
1437
1472
|
conversationId: entry.id, messageId: input?.messageId, report: onDeliveryTiming });
|
|
1438
1473
|
deliveryTiming("received");
|
|
1474
|
+
const grant = new Set();
|
|
1439
1475
|
try {
|
|
1440
|
-
await access(context, entry.id, goal ? "goal" : input?.steer === true ? "steer" : origin === "application" ? "wake" : "send", entry);
|
|
1476
|
+
await access(context, entry.id, goal ? "goal" : input?.steer === true ? "steer" : origin === "application" ? "wake" : "send", entry, grant);
|
|
1441
1477
|
if (entry.conversation?.commands) {
|
|
1442
1478
|
if (origin !== "user" || goal) throw failure("This conversation supports its original application Send operation.", "conversation_unsupported", 400);
|
|
1443
1479
|
return entry.conversation.commands.send(input, context);
|
|
@@ -1445,7 +1481,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1445
1481
|
if (entry.conversation?.native?.scoped) return entry.provider.sendNative({ input, context });
|
|
1446
1482
|
const originalInput = representation === "native" ? input : undefined;
|
|
1447
1483
|
const message = originalInput ? { ...input, text: input.displayMessage ?? input.message ?? input.text ?? "" } : input;
|
|
1448
|
-
const admission = await serial(entry, () => admitSend(entry, context, message, origin, goal, originalInput, wakeOptions, deliveryTiming));
|
|
1484
|
+
const admission = await serial(entry, () => admitSend(entry, context, message, origin, goal, originalInput, wakeOptions, deliveryTiming, grant));
|
|
1449
1485
|
const result = await (admission.receipt || admission.promise);
|
|
1450
1486
|
if (!entry.conversation) return result;
|
|
1451
1487
|
if (representation === "native") return result.nativeResult;
|
|
@@ -1459,11 +1495,12 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1459
1495
|
|
|
1460
1496
|
// Called while holding the conversation queue, including Claude's existing
|
|
1461
1497
|
// stop-then-Send goal commands. All commands retain the ordinary receipt path.
|
|
1462
|
-
async function admitSend(entry, context, input, origin = "user", goal, originalInput, wakeOptions, deliveryTiming) {
|
|
1498
|
+
async function admitSend(entry, context, input, origin = "user", goal, originalInput, wakeOptions, deliveryTiming, grant) {
|
|
1463
1499
|
deliveryTiming?.("preparation-started");
|
|
1464
1500
|
const operation = goal ? "goal" : input?.steer === true ? "steer" : origin === "application" ? "wake" : "send";
|
|
1465
|
-
await access(context, entry.id, operation, entry);
|
|
1501
|
+
await access(context, entry.id, operation, entry, grant);
|
|
1466
1502
|
await entry.conversation?.admission(context, operation);
|
|
1503
|
+
deliveryTiming?.("authorized");
|
|
1467
1504
|
if (goal) context?.signal?.throwIfAborted();
|
|
1468
1505
|
let suppliedExclusions;
|
|
1469
1506
|
if (wakeOptions !== undefined) {
|
|
@@ -1510,9 +1547,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1510
1547
|
if (!entry.conversation && !sameRequest(pending.input, message)) throw failure("This messageId already belongs to different content.", "conversation_message_conflict");
|
|
1511
1548
|
return { promise: pending.admission.promise };
|
|
1512
1549
|
}
|
|
1513
|
-
const
|
|
1514
|
-
|
|
1550
|
+
const existing = await findSubmittedTurn(entry, message.messageId);
|
|
1551
|
+
deliveryTiming?.("history-read");
|
|
1515
1552
|
const nativeState = entry.engine !== "api" && (await readState(entry));
|
|
1553
|
+
deliveryTiming?.("state-read");
|
|
1516
1554
|
const nativePending = nativeState?.request;
|
|
1517
1555
|
if (existing) {
|
|
1518
1556
|
const previous = submittedMessage(existing);
|
|
@@ -1531,7 +1569,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1531
1569
|
// No native dispatch has occurred for this request. Finish the previous
|
|
1532
1570
|
// waiter before reserving the same authored message or goal request.
|
|
1533
1571
|
await working.done;
|
|
1534
|
-
return admitSend(entry, context, input, origin, goal, originalInput, wakeOptions, deliveryTiming);
|
|
1572
|
+
return admitSend(entry, context, input, origin, goal, originalInput, wakeOptions, deliveryTiming, grant);
|
|
1535
1573
|
}
|
|
1536
1574
|
const nativeSteering = !working && (nativeState.nativeTurn?.active || entry.driver.canonicalTranscript && nativeState.binding?.codexAppServerRun &&
|
|
1537
1575
|
codexAppServerTurnStateFromAgentRun(nativeState.binding.codexAppServerRun).active);
|
|
@@ -1542,7 +1580,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1542
1580
|
}
|
|
1543
1581
|
if (working && !input.steer && !goal && !entry.conversation) throw failure("Wait for this answer or use steer to send another instruction.", "conversation_busy");
|
|
1544
1582
|
if (input.steer && (!working && !nativeSteering || working?.finishing) && !(nativePending?.attempted || nativePending?.inspectionOnly)) {
|
|
1545
|
-
const previousState = nativeState?.nativeTurn?.runState || nativeState?.nativeTurn?.state ||
|
|
1583
|
+
const previousState = nativeState?.nativeTurn?.runState || nativeState?.nativeTurn?.state ||
|
|
1584
|
+
(await lastTranscriptTurn(entry))?.metadata?.runtime?.status;
|
|
1546
1585
|
const completed = working ? !working.controller.signal.aborted && !working.timedOut : ["complete", "completed"].includes(previousState);
|
|
1547
1586
|
throw Object.assign(failure("There is no active native turn to steer. Send a new message.",
|
|
1548
1587
|
completed ? "conversation_steer_turn_finished" : "conversation_not_steerable"), { delivery: "not-sent" });
|
|
@@ -1560,7 +1599,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1560
1599
|
let nativeToolAttempt;
|
|
1561
1600
|
let excludedMessageIds;
|
|
1562
1601
|
let requestAt = new Date().toISOString();
|
|
1563
|
-
|
|
1602
|
+
// The bound state was read above unless this is an API conversation.
|
|
1603
|
+
try { configuration = entry.conversation ? (nativeState || await readState(entry)).configuration : await storage.write(entry.id, async transaction => {
|
|
1564
1604
|
const metadata = await transaction.readMetadata();
|
|
1565
1605
|
if (metadata.runtime.replacement) throw failure("Finish the pending native replacement before sending another message.", "conversation_replacement_pending");
|
|
1566
1606
|
if (retainedSegments(metadata.runtime).some(segment => segment.segmentId !== entry.segmentId && segment.request?.messageId === message.messageId)) {
|
|
@@ -1589,6 +1629,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1589
1629
|
await transaction.writeMetadata(metadata);
|
|
1590
1630
|
return metadata.runtime.configuration;
|
|
1591
1631
|
}); } catch (error) { release(); throw error; }
|
|
1632
|
+
deliveryTiming?.("request-saved");
|
|
1592
1633
|
const request = { input: message, originalInput, operation, context, at: requestAt, deliveryTiming,
|
|
1593
1634
|
...(markedResponse ? { completedEnvelope: true, excludedMessageIds } : {}),
|
|
1594
1635
|
...(typeof nativeToolAttempt === "boolean" ? { nativeToolAttempt } : {}), steering: Boolean(working || input.steer && nativeSteering),
|
|
@@ -90,64 +90,128 @@ function conversationMessageStorageKey({ role, at, messageId } = {}) {
|
|
|
90
90
|
return `${normalizedRole}.${timestamp}${idSuffix}.md`;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
function conversationMessageIndex(record) {
|
|
94
|
+
if (!record.index) {
|
|
95
|
+
record.index = new Map();
|
|
96
|
+
for (const [turnId, turn] of record.turns) {
|
|
97
|
+
for (const message of turn.messages) {
|
|
98
|
+
if (message.messageId && !record.index.has(message.messageId)) record.index.set(message.messageId, turnId);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return record.index;
|
|
103
|
+
}
|
|
104
|
+
|
|
93
105
|
/** Transaction implementation for single-writer record adapters. This serializes
|
|
94
106
|
* one instance's writes; a database with multiple writers must also hold its own
|
|
95
107
|
* transaction/lock across readRecord and writeRecord, or implement read/write directly.
|
|
108
|
+
* An adapter may supply recordVersion(id), a cheap token that changes whenever
|
|
109
|
+
* the saved record changes; the parsed record is then reused until it changes.
|
|
96
110
|
*/
|
|
97
|
-
export function createConversationStorage({ readRecord, writeRecord, deleteRecord }) {
|
|
111
|
+
export function createConversationStorage({ readRecord, writeRecord, deleteRecord, recordVersion }) {
|
|
98
112
|
const pending = new Map();
|
|
113
|
+
const cache = new Map();
|
|
99
114
|
|
|
100
115
|
function key(scope) {
|
|
101
116
|
if (typeof scope !== "string" || !scope.trim()) throw new TypeError("Conversation scope must be a nonempty string.");
|
|
102
117
|
return scope;
|
|
103
118
|
}
|
|
104
119
|
|
|
105
|
-
function
|
|
106
|
-
|
|
120
|
+
async function readSaved(id) {
|
|
121
|
+
if (typeof recordVersion !== "function") return readRecord(id);
|
|
122
|
+
const version = await recordVersion(id);
|
|
123
|
+
const cached = cache.get(id);
|
|
124
|
+
if (version != null && cached?.version === version) return cached.record;
|
|
125
|
+
const record = await readRecord(id);
|
|
126
|
+
if (version == null) cache.delete(id);
|
|
127
|
+
else cache.set(id, { version, record });
|
|
128
|
+
return record;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Saved records are shared and never changed in place. A transaction copies
|
|
132
|
+
// the turn map and each turn it changes, then commits that new record.
|
|
133
|
+
function transaction(saved) {
|
|
134
|
+
const draft = { metadata: saved.metadata, turns: saved.turns };
|
|
135
|
+
const added = new Map();
|
|
136
|
+
let ownTurns = false;
|
|
137
|
+
const owned = new Set();
|
|
138
|
+
function ownTurn(id) {
|
|
139
|
+
if (!ownTurns) {
|
|
140
|
+
draft.turns = new Map(draft.turns);
|
|
141
|
+
ownTurns = true;
|
|
142
|
+
}
|
|
143
|
+
const existing = draft.turns.get(id);
|
|
144
|
+
if (existing && owned.has(id)) return existing;
|
|
145
|
+
const turn = existing ? { ...existing, messages: [...existing.messages] } : { messages: [] };
|
|
146
|
+
draft.turns.set(id, turn);
|
|
147
|
+
owned.add(id);
|
|
148
|
+
return turn;
|
|
149
|
+
}
|
|
150
|
+
function record() {
|
|
151
|
+
// Index entries are verified against the turn on lookup, so removed
|
|
152
|
+
// messages can remain listed; only additions need new entries.
|
|
153
|
+
if (!added.size) return saved.index ? { ...draft, index: saved.index } : draft;
|
|
154
|
+
const index = new Map(conversationMessageIndex(saved));
|
|
155
|
+
for (const [messageId, turnId] of added) index.set(messageId, turnId);
|
|
156
|
+
return { ...draft, index };
|
|
157
|
+
}
|
|
158
|
+
const turns = () => draft.turns;
|
|
159
|
+
function messageTurnId(id) {
|
|
160
|
+
if (!id) return "";
|
|
161
|
+
const turnId = added.get(id) ?? conversationMessageIndex(saved).get(id);
|
|
162
|
+
if (turnId === undefined) return "";
|
|
163
|
+
const contains = (turn) => turn.messages.some((message) => message.messageId === id);
|
|
164
|
+
if (turns().get(turnId) && contains(turns().get(turnId))) return turnId;
|
|
165
|
+
return [...turns()].find(([, turn]) => contains(turn))?.[0] || "";
|
|
166
|
+
}
|
|
107
167
|
return {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
conversationMessageStorageKey(
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
168
|
+
record,
|
|
169
|
+
view: {
|
|
170
|
+
async readMetadata() { return structuredClone(draft.metadata); },
|
|
171
|
+
async writeMetadata(value) { draft.metadata = structuredClone(value); },
|
|
172
|
+
async updateTurnMetadata(id, patch) {
|
|
173
|
+
if (!turns().get(id)) throw new Error("The conversation turn does not exist.");
|
|
174
|
+
const turn = ownTurn(id);
|
|
175
|
+
turn.metadata = { ...turn.metadata, ...structuredClone(patch) };
|
|
176
|
+
},
|
|
177
|
+
async listTurnIds() { return [...turns().keys()]; },
|
|
178
|
+
async nextTurnId() { return String(Math.max(0, ...[...turns().keys()].map(Number).filter(Number.isSafeInteger)) + 1).padStart(6, "0"); },
|
|
179
|
+
async hasMessage(id) { return Boolean(messageTurnId(id)); },
|
|
180
|
+
// Optional transaction extension: the turn holding this message ID.
|
|
181
|
+
async findMessageTurnId(id) { return messageTurnId(id); },
|
|
182
|
+
async readTurn(id) {
|
|
183
|
+
const turn = turns().get(id);
|
|
184
|
+
if (!turn) return null;
|
|
185
|
+
const find = (role) => turn.messages.find((message) => message.role === role) || null;
|
|
186
|
+
const thinking = turn.messages.filter((message) => message.role === "thinking");
|
|
187
|
+
const commentary = turn.messages.filter((message) => message.role === "commentary");
|
|
188
|
+
const activity = [...thinking, ...commentary].sort((a, b) => a.at.localeCompare(b.at));
|
|
189
|
+
const system = find("system"), user = find("user"), assistant = find("assistant");
|
|
190
|
+
return structuredClone({
|
|
191
|
+
turnId: id, user, assistant, thinking, commentary,
|
|
192
|
+
...(system ? { system } : {}), ...(turn.metadata ? { metadata: turn.metadata } : {}),
|
|
193
|
+
messages: [system, user, ...activity, assistant].filter(Boolean)
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
async appendMessage(id, { turnMetadata, ...message }) {
|
|
197
|
+
const turn = ownTurn(id);
|
|
198
|
+
if (turnMetadata) turn.metadata = structuredClone(turnMetadata);
|
|
199
|
+
const identity = conversationMessageStorageKey(message);
|
|
200
|
+
const existing = identity === null ? -1 : turn.messages.findIndex((previous) =>
|
|
201
|
+
conversationMessageStorageKey(previous) === identity);
|
|
202
|
+
if (existing < 0) turn.messages.push(structuredClone(message));
|
|
203
|
+
else turn.messages[existing] = structuredClone(message);
|
|
204
|
+
if (message.messageId) added.set(message.messageId, id);
|
|
205
|
+
},
|
|
206
|
+
async replaceAssistant(id, message) {
|
|
207
|
+
if (!id) throw new TypeError("Assistant replacement requires a turn id.");
|
|
208
|
+
const turn = ownTurn(id);
|
|
209
|
+
const previous = turn.messages.find((entry) => entry.role === "assistant");
|
|
210
|
+
turn.messages = turn.messages.filter((entry) => entry.role !== "assistant");
|
|
211
|
+
const replacement = { ...previous, ...structuredClone(message), at: previous?.at || message.at };
|
|
212
|
+
turn.messages.push(replacement);
|
|
213
|
+
if (replacement.messageId) added.set(replacement.messageId, id);
|
|
214
|
+
}
|
|
151
215
|
}
|
|
152
216
|
};
|
|
153
217
|
}
|
|
@@ -157,11 +221,17 @@ export function createConversationStorage({ readRecord, writeRecord, deleteRecor
|
|
|
157
221
|
const id = key(scope);
|
|
158
222
|
const previous = pending.get(id) || Promise.resolve();
|
|
159
223
|
const operation = previous.then(async () => {
|
|
160
|
-
const draft =
|
|
161
|
-
const
|
|
162
|
-
|
|
224
|
+
const draft = transaction(await readSaved(id));
|
|
225
|
+
const result = await callback(draft.view);
|
|
226
|
+
// A failed or partial save leaves the cached record unknown.
|
|
227
|
+
cache.delete(id);
|
|
163
228
|
if (remove) await deleteRecord(id);
|
|
164
|
-
else
|
|
229
|
+
else {
|
|
230
|
+
const record = draft.record();
|
|
231
|
+
await writeRecord(id, record, draft.view);
|
|
232
|
+
const version = typeof recordVersion === "function" ? await recordVersion(id) : null;
|
|
233
|
+
if (version != null) cache.set(id, { version, record });
|
|
234
|
+
}
|
|
165
235
|
return result;
|
|
166
236
|
});
|
|
167
237
|
const settled = operation.then(() => undefined, () => undefined);
|
|
@@ -175,7 +245,7 @@ export function createConversationStorage({ readRecord, writeRecord, deleteRecor
|
|
|
175
245
|
async read(scope, callback) {
|
|
176
246
|
const id = key(scope);
|
|
177
247
|
await pending.get(id);
|
|
178
|
-
return callback(transaction(
|
|
248
|
+
return callback(transaction(await readSaved(id)).view);
|
|
179
249
|
},
|
|
180
250
|
async deleteConversation(scope) {
|
|
181
251
|
await write(scope, async () => {}, true);
|
|
@@ -92,8 +92,29 @@ export function createConversationTranscript({ storage, clock = () => new Date()
|
|
|
92
92
|
});
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// The first turn whose submitted (user or application) message has this ID.
|
|
96
|
+
async function findSubmittedTurn(scope, messageId) {
|
|
97
|
+
const id = normalizeText(messageId);
|
|
98
|
+
if (!id) return null;
|
|
99
|
+
return storage.read(scope, async (transaction) => {
|
|
100
|
+
const submitted = turn => hasMessages(turn) && (turn.user || turn.system)?.messageId === id ? turn : null;
|
|
101
|
+
if (typeof transaction.findMessageTurnId === "function") {
|
|
102
|
+
const turnId = await transaction.findMessageTurnId(id);
|
|
103
|
+
if (!turnId) return null;
|
|
104
|
+
const turn = submitted(await transaction.readTurn(turnId));
|
|
105
|
+
if (turn) return turn;
|
|
106
|
+
}
|
|
107
|
+
for (const turnId of await transaction.listTurnIds()) {
|
|
108
|
+
const turn = submitted(await transaction.readTurn(turnId));
|
|
109
|
+
if (turn) return turn;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
95
115
|
return Object.freeze({
|
|
96
116
|
readConversationLog,
|
|
117
|
+
findSubmittedTurn,
|
|
97
118
|
readConversationLogPage,
|
|
98
119
|
conversationMessageIdExists: (scope, messageId) => storage.read(scope, (transaction) => transaction.hasMessage(normalizeText(messageId))),
|
|
99
120
|
writeConversationUserMessage: (scope, input) => append(scope, "user", input),
|
|
@@ -41,6 +41,29 @@ test("Claude account checks use the supplied finite capture policy without start
|
|
|
41
41
|
assert.ok(checks.length > 0);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
+
test("a warm Claude send neither rescans the whole transcript nor authorizes its admission twice", async t => {
|
|
45
|
+
let scans = 0;
|
|
46
|
+
const authorizations = [];
|
|
47
|
+
const f = await fixture(t, {
|
|
48
|
+
authorize: ({ operation }) => { authorizations.push(operation); return true; },
|
|
49
|
+
wrapStorage: storage => ({ ...storage, read: (scope, callback) => storage.read(scope, transaction => callback(new Proxy(transaction, {
|
|
50
|
+
get(target, property) {
|
|
51
|
+
if (property === "listTurnIds") scans += 1;
|
|
52
|
+
return target[property];
|
|
53
|
+
}
|
|
54
|
+
}))) })
|
|
55
|
+
});
|
|
56
|
+
assert.equal((await f.conversation.send(input)).status, "accepted");
|
|
57
|
+
await f.conversation.wait();
|
|
58
|
+
scans = 0;
|
|
59
|
+
authorizations.length = 0;
|
|
60
|
+
assert.equal((await f.conversation.send({ messageId: "second", text: "Hello again" })).status, "accepted");
|
|
61
|
+
await f.conversation.wait();
|
|
62
|
+
assert.equal(scans, 2, "Only native catch-up history and the final snapshot read the whole log; admission uses message identity.");
|
|
63
|
+
assert.equal(authorizations.filter(operation => operation === "send").length, 3,
|
|
64
|
+
"Send shares one grant with its admission; dispatch and receipt admission authorize again.");
|
|
65
|
+
});
|
|
66
|
+
|
|
44
67
|
test("Claude exposes the durable native identity before dispatch and respects a rejected gate", async t => {
|
|
45
68
|
const f = await fixture(t);
|
|
46
69
|
await f.first.close();
|
package/test/claudeTurn.test.js
CHANGED
|
@@ -1449,11 +1449,14 @@ test("Claude delivery timing separates native acknowledgement from the held cano
|
|
|
1449
1449
|
const sending = f.conversation.send({ messageId: "timed-request", text: "Private message" });
|
|
1450
1450
|
await writing.promise;
|
|
1451
1451
|
assert.deepEqual(timings.map(event => event.stage), [
|
|
1452
|
-
"received", "preparation-started", "
|
|
1452
|
+
"received", "preparation-started", "authorized", "history-read", "state-read", "request-saved", "input-prepared",
|
|
1453
|
+
"provider-started", "duplicate-checked", "native-history-read", "process-ready", "prompt-rendered", "metadata-ready",
|
|
1454
|
+
"runtime-ready", "dispatch-started", "native-acknowledged"
|
|
1453
1455
|
]);
|
|
1454
1456
|
release.resolve();
|
|
1455
1457
|
assert.equal((await sending).status, "accepted");
|
|
1456
|
-
|
|
1458
|
+
const admitted = timings.map(event => event.stage).slice(16);
|
|
1459
|
+
assert.deepEqual(admitted, ["receipt-written", "state-active", "receipt-persisted", "admission-authorized", "turn-located", "accepted"]);
|
|
1457
1460
|
await f.frame({ type: "result", subtype: "success", result: "Public answer" });
|
|
1458
1461
|
await f.conversation.wait();
|
|
1459
1462
|
assert.equal(JSON.stringify(timings).includes("Private message"), false);
|
|
@@ -2298,8 +2301,14 @@ test("bound Main history budget transfers more than 30 complete messages through
|
|
|
2298
2301
|
f.binding.transcript.history = async () => history;
|
|
2299
2302
|
await f.binding.state.write({ lastEngine: "codex", engines: { codex: { seen: {} } } });
|
|
2300
2303
|
let reads = 0;
|
|
2301
|
-
|
|
2304
|
+
const phases = [];
|
|
2305
|
+
const canonical = await f.runtime.open({ id: "lesson", context: f.toolContext });
|
|
2306
|
+
f.binding.maximumHistoryBytes = async ({ engineId, returning, context, signal, input, setPhase }) => {
|
|
2302
2307
|
reads++;
|
|
2308
|
+
await setPhase("compacting");
|
|
2309
|
+
phases.push((await canonical.read()).phase);
|
|
2310
|
+
await setPhase("preparing");
|
|
2311
|
+
phases.push((await canonical.read()).phase);
|
|
2303
2312
|
assert.equal(engineId, "claude");
|
|
2304
2313
|
assert.equal(returning, false);
|
|
2305
2314
|
assert.equal(context.actor.id, "owner");
|
|
@@ -2314,6 +2323,7 @@ test("bound Main history budget transfers more than 30 complete messages through
|
|
|
2314
2323
|
};
|
|
2315
2324
|
await sendCommon(f, "full-history");
|
|
2316
2325
|
assert.equal(reads, 1);
|
|
2326
|
+
assert.deepEqual(phases, ["compacting", "preparing"], "a host freeing native context reports it as compacting");
|
|
2317
2327
|
assert.ok(sent[0].length > 128_000);
|
|
2318
2328
|
for (const message of history) assert.ok(sent[0].includes(JSON.stringify(message.text)), message.id);
|
|
2319
2329
|
await f.frame({ type: "result", subtype: "success", result: "Received." });
|
|
@@ -2586,3 +2596,45 @@ test("Claude explicitly pauses a blocked goal without resuming native work", asy
|
|
|
2586
2596
|
assert.equal(f.entry.pausedGoal.status, "paused");
|
|
2587
2597
|
assert.equal(f.processes.length, 0);
|
|
2588
2598
|
});
|
|
2599
|
+
|
|
2600
|
+
test("live Claude history parses only frames appended since the previous read", async t => {
|
|
2601
|
+
const { appendFile, mkdir: makeDirectory, mkdtemp: makeTemporary, rename: move, rm: remove, writeFile: write } = await import("node:fs/promises");
|
|
2602
|
+
const root = await makeTemporary(path.join(os.tmpdir(), "claude-incremental-"));
|
|
2603
|
+
t.after(() => remove(root, { recursive: true, force: true }));
|
|
2604
|
+
const directory = path.join(root, "projects", root.replace(/[^a-zA-Z0-9]/gu, "-"));
|
|
2605
|
+
await makeDirectory(directory, { recursive: true });
|
|
2606
|
+
const conversationId = "7b1f9a52-1c1e-4d0b-9a55-3c8e2f4d6a10";
|
|
2607
|
+
const file = path.join(directory, `${conversationId}.jsonl`);
|
|
2608
|
+
const options = { configRoot: root, workdir: root, conversationId };
|
|
2609
|
+
const user = id => JSON.stringify({ type: "user", uuid: id, message: { content: `Request ${id}` } });
|
|
2610
|
+
const answer = (id, parent) => JSON.stringify({ type: "assistant", uuid: id, parentUuid: parent,
|
|
2611
|
+
message: { id: `api-${id}`, content: [{ type: "text", text: `Answer ${id}` }] } });
|
|
2612
|
+
await write(file, [user("u1"), answer("a1", "u1")].join("\n") + "\n");
|
|
2613
|
+
const parse = JSON.parse;
|
|
2614
|
+
let parsed = 0;
|
|
2615
|
+
JSON.parse = (...args) => { parsed += 1; return parse(...args); };
|
|
2616
|
+
try {
|
|
2617
|
+
assert.deepEqual((await readClaudeHistory(options)).userIds, ["u1"]);
|
|
2618
|
+
assert.equal(parsed, 2);
|
|
2619
|
+
parsed = 0;
|
|
2620
|
+
// A complete appended frame is parsed once; the writer's unfinished tail is not.
|
|
2621
|
+
await appendFile(file, user("u2") + "\n" + user("u3").slice(0, 10));
|
|
2622
|
+
assert.deepEqual((await readClaudeHistory(options)).userIds, ["u1", "u2"]);
|
|
2623
|
+
assert.equal(parsed, 1);
|
|
2624
|
+
parsed = 0;
|
|
2625
|
+
await appendFile(file, user("u3").slice(10) + "\n");
|
|
2626
|
+
const history = await readClaudeHistory(options);
|
|
2627
|
+
assert.deepEqual(history.userIds, ["u1", "u2", "u3"]);
|
|
2628
|
+
assert.deepEqual(history.messages.map(message => message.text), ["Answer a1"]);
|
|
2629
|
+
assert.equal(parsed, 1);
|
|
2630
|
+
parsed = 0;
|
|
2631
|
+
assert.deepEqual((await readClaudeHistory(options)).userIds, ["u1", "u2", "u3"]);
|
|
2632
|
+
assert.equal(parsed, 0);
|
|
2633
|
+
// Replacement and truncation discard the parsed prefix.
|
|
2634
|
+
await write(`${file}.next`, [user("r1"), answer("ra", "r1")].join("\n") + "\n");
|
|
2635
|
+
await move(`${file}.next`, file);
|
|
2636
|
+
assert.deepEqual((await readClaudeHistory(options)).userIds, ["r1"]);
|
|
2637
|
+
await write(file, user("t1") + "\n");
|
|
2638
|
+
assert.deepEqual((await readClaudeHistory(options)).userIds, ["t1"]);
|
|
2639
|
+
} finally { JSON.parse = parse; }
|
|
2640
|
+
});
|
|
@@ -1265,8 +1265,10 @@ test("Codex delivery timing uses the common native receipt and first-response st
|
|
|
1265
1265
|
assert.equal((await f.conversation.send({ messageId: "timed-request", text: "Private message" })).status, "accepted");
|
|
1266
1266
|
await f.conversation.wait();
|
|
1267
1267
|
const stages = timings.map(event => event.stage);
|
|
1268
|
-
|
|
1269
|
-
"
|
|
1268
|
+
const common = ["received", "preparation-started", "provider-started", "runtime-ready", "dispatch-started",
|
|
1269
|
+
"native-acknowledged", "receipt-persisted", "accepted"];
|
|
1270
|
+
assert.deepEqual(stages.filter(stage => common.includes(stage)).slice(0, 8), common);
|
|
1271
|
+
assert.deepEqual(stages.slice(2, 7), ["authorized", "history-read", "state-read", "request-saved", "input-prepared"]);
|
|
1270
1272
|
assert.equal(stages.filter(stage => stage === "first-response").length, 1);
|
|
1271
1273
|
assert.equal(JSON.stringify(timings).includes("Private message"), false);
|
|
1272
1274
|
});
|
|
@@ -71,3 +71,34 @@ test("application scopes cannot traverse paths and deletion remains isolated", a
|
|
|
71
71
|
assert.deepEqual(await f.transcript.readConversationLog("../../other"), []);
|
|
72
72
|
assert.equal((await f.transcript.readConversationLog("other"))[0].user.text, "Two");
|
|
73
73
|
});
|
|
74
|
+
|
|
75
|
+
test("saved conversations are parsed once until they change and look messages up by identity", async (t) => {
|
|
76
|
+
const f = await fixture(t);
|
|
77
|
+
await f.transcript.writeConversationUserMessage("cached", { text: "First", messageId: "first", at: "2026-01-01T00:00:00Z" });
|
|
78
|
+
await f.transcript.writeConversationUserMessage("cached", { text: "Second", messageId: "second", at: "2026-01-01T00:00:01Z" });
|
|
79
|
+
const [file] = (await readdir(f.directory)).filter(name => name.endsWith(".json"));
|
|
80
|
+
const parse = JSON.parse;
|
|
81
|
+
let parsed = 0;
|
|
82
|
+
JSON.parse = (...args) => { parsed += 1; return parse(...args); };
|
|
83
|
+
try {
|
|
84
|
+
assert.equal((await f.transcript.findSubmittedTurn("cached", "second")).user.text, "Second");
|
|
85
|
+
assert.equal(await f.transcript.conversationMessageIdExists("cached", "first"), true);
|
|
86
|
+
assert.equal(await f.transcript.conversationMessageIdExists("cached", "missing"), false);
|
|
87
|
+
assert.equal(await f.transcript.findSubmittedTurn("cached", "missing"), null);
|
|
88
|
+
assert.equal((await f.transcript.readConversationLog("cached")).length, 2);
|
|
89
|
+
assert.equal(parsed, 0, "The record written by this process is reused.");
|
|
90
|
+
// An external writer changes the saved file; the next read parses it.
|
|
91
|
+
const saved = parse(await readFile(join(f.directory, file), "utf8"));
|
|
92
|
+
saved.turns[1][1].messages[0].text = "Edited elsewhere";
|
|
93
|
+
await writeFile(join(f.directory, file), JSON.stringify(saved));
|
|
94
|
+
assert.equal((await f.transcript.findSubmittedTurn("cached", "second")).user.text, "Edited elsewhere");
|
|
95
|
+
assert.equal(parsed, 1);
|
|
96
|
+
} finally { JSON.parse = parse; }
|
|
97
|
+
// Changes made inside a failed write or a read never reach the saved record.
|
|
98
|
+
await assert.rejects(f.storage.write("cached", async transaction => {
|
|
99
|
+
await transaction.appendMessage("000001", { role: "assistant", text: "Discarded", at: "2026-01-01T00:00:02Z" });
|
|
100
|
+
throw new Error("rolled back");
|
|
101
|
+
}), /rolled back/);
|
|
102
|
+
await f.storage.read("cached", transaction => transaction.appendMessage("000001", { role: "assistant", text: "Read only", at: "2026-01-01T00:00:03Z" }));
|
|
103
|
+
assert.equal((await f.transcript.readConversationLog("cached"))[0].assistant, null);
|
|
104
|
+
});
|
|
@@ -257,8 +257,10 @@ test("OpenCode delivery timing uses the common receipt stages without recording
|
|
|
257
257
|
assert.equal(receipt.status, "accepted");
|
|
258
258
|
await f.conversation.wait();
|
|
259
259
|
const stages = timings.map(event => event.stage);
|
|
260
|
-
|
|
261
|
-
"native-acknowledged", "receipt-persisted", "accepted"]
|
|
260
|
+
const common = ["received", "preparation-started", "provider-started", "runtime-ready", "dispatch-started",
|
|
261
|
+
"native-acknowledged", "receipt-persisted", "accepted"];
|
|
262
|
+
assert.deepEqual(stages.filter(stage => common.includes(stage)).slice(0, 8), common);
|
|
263
|
+
assert.deepEqual(stages.slice(2, 7), ["authorized", "history-read", "state-read", "request-saved", "input-prepared"]);
|
|
262
264
|
assert.equal(stages.filter(stage => stage === "first-response").length, 1);
|
|
263
265
|
assert.equal(JSON.stringify(timings).includes("Private message"), false);
|
|
264
266
|
});
|