@jskit-ai/assistant-core 0.1.232 → 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 +56 -17
- 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 +47 -2
- 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") {
|
|
@@ -827,13 +853,15 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
827
853
|
|
|
828
854
|
async function admit(request, seen, { nativeDelivery = false, conversationTurn } = {}) {
|
|
829
855
|
await access(request.context, entry.id, request.operation, entry);
|
|
856
|
+
request.deliveryTiming?.("admission-authorized");
|
|
830
857
|
if (!conversationTurn) active.controller.signal.throwIfAborted();
|
|
831
858
|
if (request.input.goal) request.goalMessageId = request.input.messageId;
|
|
832
859
|
else if (goalOwner) request.goalMessageId = goalOwner.input.messageId;
|
|
833
860
|
const nativeTurnId = active.nativeTurnId;
|
|
834
861
|
const previous = current;
|
|
835
862
|
if (entry.conversation) {
|
|
836
|
-
const saved =
|
|
863
|
+
const saved = conversationTurn?.turnId ? await readBoundTurn(entry, conversationTurn.turnId) : null;
|
|
864
|
+
request.deliveryTiming?.("turn-located");
|
|
837
865
|
if (submittedMessage(saved || {})?.messageId !== request.input.messageId) {
|
|
838
866
|
throw new Error("The native receipt does not belong to this authored request.");
|
|
839
867
|
}
|
|
@@ -1011,7 +1039,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1011
1039
|
}
|
|
1012
1040
|
|
|
1013
1041
|
async function deliverNative(request, dispatch) {
|
|
1014
|
-
|
|
1042
|
+
// A bound conversation supplies its own native history below.
|
|
1043
|
+
const history = entry.conversation ? [] : (await readTranscript(entry)).filter(turn => !turn.metadata?.runtime?.supersededBy);
|
|
1015
1044
|
let completion;
|
|
1016
1045
|
let committed;
|
|
1017
1046
|
let rendered;
|
|
@@ -1029,10 +1058,12 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1029
1058
|
const readAttachments = attachmentReader(request);
|
|
1030
1059
|
// The bound host has already resolved its original attachment records;
|
|
1031
1060
|
// the production preparation owner keeps their manifest/native mapping.
|
|
1032
|
-
const boundInput = entry.conversation
|
|
1061
|
+
const boundInput = entry.conversation
|
|
1062
|
+
? await entry.conversation.prepareInput(input, request.context, { deliveryTiming: request.deliveryTiming }) : null;
|
|
1033
1063
|
const files = boundInput ? { attachments: boundInput.displayAttachments || [], localFiles: [], content: [] }
|
|
1034
1064
|
: await readAttachments((input.attachmentIds || []).map(attachmentId => ({ attachmentId })));
|
|
1035
1065
|
const contextFiles = boundInput ? { localFiles: [], content: [] } : await readAttachments(input.contextAttachments || []);
|
|
1066
|
+
request.deliveryTiming?.("input-prepared");
|
|
1036
1067
|
const localFiles = [...contextFiles.localFiles, ...files.localFiles];
|
|
1037
1068
|
if (request.input.goal && localFiles.length) {
|
|
1038
1069
|
throw failure("Native goals require extracted text or supported image bytes for attachments.", "conversation_unsupported", 400);
|
|
@@ -1066,6 +1097,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1066
1097
|
text: input.message, contextText: input.contextText || "",
|
|
1067
1098
|
content: [...contextFiles.content, ...files.content], localFiles, attachmentManifest },
|
|
1068
1099
|
signal: active.controller.signal,
|
|
1100
|
+
deliveryTiming: request.deliveryTiming,
|
|
1069
1101
|
onNativeReceipt: () => request.deliveryTiming?.("native-acknowledged"),
|
|
1070
1102
|
onReceiptPersisted: () => request.deliveryTiming?.("receipt-persisted"),
|
|
1071
1103
|
...(engine === "opencode" ? { deliveryCommitted: committed.promise } : {}),
|
|
@@ -1170,7 +1202,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1170
1202
|
}
|
|
1171
1203
|
if (request.accepted) await publishAdmission(request, nativeResult);
|
|
1172
1204
|
else {
|
|
1173
|
-
const saved =
|
|
1205
|
+
const saved = await findSubmittedTurn(entry, request.input.messageId);
|
|
1174
1206
|
if (!saved && entry.conversation) {
|
|
1175
1207
|
// An original native-history duplicate need not have an authored
|
|
1176
1208
|
// application row. Preserve its exact result without inventing a
|
|
@@ -1226,7 +1258,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1226
1258
|
|
|
1227
1259
|
try {
|
|
1228
1260
|
await setPhase(entry, "preparing");
|
|
1229
|
-
|
|
1261
|
+
// Only the API engine sends the application transcript as history.
|
|
1262
|
+
const recordedHistory = engine === "api" ? await readTranscript(entry) : [];
|
|
1230
1263
|
const activeHistory = recordedHistory.filter(turn => !turn.metadata?.runtime?.supersededBy);
|
|
1231
1264
|
const metadata = { runtime: await readState(entry) };
|
|
1232
1265
|
const continuity = metadata.runtime.continuity;
|
|
@@ -1438,8 +1471,9 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1438
1471
|
const deliveryTiming = createConversationDeliveryTiming({ engine: entry.engine,
|
|
1439
1472
|
conversationId: entry.id, messageId: input?.messageId, report: onDeliveryTiming });
|
|
1440
1473
|
deliveryTiming("received");
|
|
1474
|
+
const grant = new Set();
|
|
1441
1475
|
try {
|
|
1442
|
-
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);
|
|
1443
1477
|
if (entry.conversation?.commands) {
|
|
1444
1478
|
if (origin !== "user" || goal) throw failure("This conversation supports its original application Send operation.", "conversation_unsupported", 400);
|
|
1445
1479
|
return entry.conversation.commands.send(input, context);
|
|
@@ -1447,7 +1481,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1447
1481
|
if (entry.conversation?.native?.scoped) return entry.provider.sendNative({ input, context });
|
|
1448
1482
|
const originalInput = representation === "native" ? input : undefined;
|
|
1449
1483
|
const message = originalInput ? { ...input, text: input.displayMessage ?? input.message ?? input.text ?? "" } : input;
|
|
1450
|
-
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));
|
|
1451
1485
|
const result = await (admission.receipt || admission.promise);
|
|
1452
1486
|
if (!entry.conversation) return result;
|
|
1453
1487
|
if (representation === "native") return result.nativeResult;
|
|
@@ -1461,11 +1495,12 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1461
1495
|
|
|
1462
1496
|
// Called while holding the conversation queue, including Claude's existing
|
|
1463
1497
|
// stop-then-Send goal commands. All commands retain the ordinary receipt path.
|
|
1464
|
-
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) {
|
|
1465
1499
|
deliveryTiming?.("preparation-started");
|
|
1466
1500
|
const operation = goal ? "goal" : input?.steer === true ? "steer" : origin === "application" ? "wake" : "send";
|
|
1467
|
-
await access(context, entry.id, operation, entry);
|
|
1501
|
+
await access(context, entry.id, operation, entry, grant);
|
|
1468
1502
|
await entry.conversation?.admission(context, operation);
|
|
1503
|
+
deliveryTiming?.("authorized");
|
|
1469
1504
|
if (goal) context?.signal?.throwIfAborted();
|
|
1470
1505
|
let suppliedExclusions;
|
|
1471
1506
|
if (wakeOptions !== undefined) {
|
|
@@ -1512,9 +1547,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1512
1547
|
if (!entry.conversation && !sameRequest(pending.input, message)) throw failure("This messageId already belongs to different content.", "conversation_message_conflict");
|
|
1513
1548
|
return { promise: pending.admission.promise };
|
|
1514
1549
|
}
|
|
1515
|
-
const
|
|
1516
|
-
|
|
1550
|
+
const existing = await findSubmittedTurn(entry, message.messageId);
|
|
1551
|
+
deliveryTiming?.("history-read");
|
|
1517
1552
|
const nativeState = entry.engine !== "api" && (await readState(entry));
|
|
1553
|
+
deliveryTiming?.("state-read");
|
|
1518
1554
|
const nativePending = nativeState?.request;
|
|
1519
1555
|
if (existing) {
|
|
1520
1556
|
const previous = submittedMessage(existing);
|
|
@@ -1533,7 +1569,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1533
1569
|
// No native dispatch has occurred for this request. Finish the previous
|
|
1534
1570
|
// waiter before reserving the same authored message or goal request.
|
|
1535
1571
|
await working.done;
|
|
1536
|
-
return admitSend(entry, context, input, origin, goal, originalInput, wakeOptions, deliveryTiming);
|
|
1572
|
+
return admitSend(entry, context, input, origin, goal, originalInput, wakeOptions, deliveryTiming, grant);
|
|
1537
1573
|
}
|
|
1538
1574
|
const nativeSteering = !working && (nativeState.nativeTurn?.active || entry.driver.canonicalTranscript && nativeState.binding?.codexAppServerRun &&
|
|
1539
1575
|
codexAppServerTurnStateFromAgentRun(nativeState.binding.codexAppServerRun).active);
|
|
@@ -1544,7 +1580,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1544
1580
|
}
|
|
1545
1581
|
if (working && !input.steer && !goal && !entry.conversation) throw failure("Wait for this answer or use steer to send another instruction.", "conversation_busy");
|
|
1546
1582
|
if (input.steer && (!working && !nativeSteering || working?.finishing) && !(nativePending?.attempted || nativePending?.inspectionOnly)) {
|
|
1547
|
-
const previousState = nativeState?.nativeTurn?.runState || nativeState?.nativeTurn?.state ||
|
|
1583
|
+
const previousState = nativeState?.nativeTurn?.runState || nativeState?.nativeTurn?.state ||
|
|
1584
|
+
(await lastTranscriptTurn(entry))?.metadata?.runtime?.status;
|
|
1548
1585
|
const completed = working ? !working.controller.signal.aborted && !working.timedOut : ["complete", "completed"].includes(previousState);
|
|
1549
1586
|
throw Object.assign(failure("There is no active native turn to steer. Send a new message.",
|
|
1550
1587
|
completed ? "conversation_steer_turn_finished" : "conversation_not_steerable"), { delivery: "not-sent" });
|
|
@@ -1562,7 +1599,8 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1562
1599
|
let nativeToolAttempt;
|
|
1563
1600
|
let excludedMessageIds;
|
|
1564
1601
|
let requestAt = new Date().toISOString();
|
|
1565
|
-
|
|
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 => {
|
|
1566
1604
|
const metadata = await transaction.readMetadata();
|
|
1567
1605
|
if (metadata.runtime.replacement) throw failure("Finish the pending native replacement before sending another message.", "conversation_replacement_pending");
|
|
1568
1606
|
if (retainedSegments(metadata.runtime).some(segment => segment.segmentId !== entry.segmentId && segment.request?.messageId === message.messageId)) {
|
|
@@ -1591,6 +1629,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1591
1629
|
await transaction.writeMetadata(metadata);
|
|
1592
1630
|
return metadata.runtime.configuration;
|
|
1593
1631
|
}); } catch (error) { release(); throw error; }
|
|
1632
|
+
deliveryTiming?.("request-saved");
|
|
1594
1633
|
const request = { input: message, originalInput, operation, context, at: requestAt, deliveryTiming,
|
|
1595
1634
|
...(markedResponse ? { completedEnvelope: true, excludedMessageIds } : {}),
|
|
1596
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);
|
|
@@ -2593,3 +2596,45 @@ test("Claude explicitly pauses a blocked goal without resuming native work", asy
|
|
|
2593
2596
|
assert.equal(f.entry.pausedGoal.status, "paused");
|
|
2594
2597
|
assert.equal(f.processes.length, 0);
|
|
2595
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
|
});
|