@jskit-ai/assistant-core 0.1.232 → 0.1.234

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.
@@ -8,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.232",
11
+ "@jskit-ai/assistant-core": "0.1.234",
12
12
  "@jskit-ai/http-runtime": "0.1.242",
13
13
  "@jskit-ai/kernel": "0.1.244",
14
14
  "@mdi/js": "^7.4.47",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.232",
3
+ "version": "0.1.234",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -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
- const frames = [];
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
- for await (const frame of readClaudeJsonFrames(createReadStream(file.path, { end: file.size - 1 }), {
121
- allowIncompleteTail: options.allowIncompleteTail ?? true
122
- })) {
123
- if (frame.isSidechain) continue;
124
- frames.push(frame);
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 }, { applicationCommand });
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", "provider-started", "runtime-ready", "dispatch-started",
3
- "native-acknowledged", "receipt-persisted", "accepted", "first-response", "failed"
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
- async function access(context, id, operation, entry) {
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 (!await authorize({ context, conversationId: id, operation })) {
89
- throw failure("This conversation is not available to this identity.", "conversation_forbidden", 403);
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 = (await readTranscript(entry)).find(turn => turn.turnId === conversationTurn?.turnId);
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
- const history = (await readTranscript(entry)).filter(turn => !turn.metadata?.runtime?.supersededBy);
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 ? await entry.conversation.prepareInput(input, request.context) : null;
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 = (await readTranscript(entry)).find(turn => submittedMessage(turn)?.messageId === request.input.messageId);
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
- const recordedHistory = await readTranscript(entry);
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 history = await readTranscript(entry);
1516
- const existing = history.find(turn => submittedMessage(turn)?.messageId === message.messageId);
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 || history.at(-1)?.metadata?.runtime?.status;
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
- try { configuration = entry.conversation ? (await readState(entry)).configuration : await storage.write(entry.id, async transaction => {
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,129 @@ 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 transaction(record) {
106
- const turns = record.turns;
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
+ // Adapter fields the transaction does not manage are carried unchanged.
134
+ function transaction(saved) {
135
+ const { index: _index, ...draft } = saved;
136
+ const added = new Map();
137
+ let ownTurns = false;
138
+ const owned = new Set();
139
+ function ownTurn(id) {
140
+ if (!ownTurns) {
141
+ draft.turns = new Map(draft.turns);
142
+ ownTurns = true;
143
+ }
144
+ const existing = draft.turns.get(id);
145
+ if (existing && owned.has(id)) return existing;
146
+ const turn = existing ? { ...existing, messages: [...existing.messages] } : { messages: [] };
147
+ draft.turns.set(id, turn);
148
+ owned.add(id);
149
+ return turn;
150
+ }
151
+ function record() {
152
+ // Index entries are verified against the turn on lookup, so removed
153
+ // messages can remain listed; only additions need new entries.
154
+ if (!added.size) return saved.index ? { ...draft, index: saved.index } : draft;
155
+ const index = new Map(conversationMessageIndex(saved));
156
+ for (const [messageId, turnId] of added) index.set(messageId, turnId);
157
+ return { ...draft, index };
158
+ }
159
+ const turns = () => draft.turns;
160
+ function messageTurnId(id) {
161
+ if (!id) return "";
162
+ const turnId = added.get(id) ?? conversationMessageIndex(saved).get(id);
163
+ if (turnId === undefined) return "";
164
+ const contains = (turn) => turn.messages.some((message) => message.messageId === id);
165
+ if (turns().get(turnId) && contains(turns().get(turnId))) return turnId;
166
+ return [...turns()].find(([, turn]) => contains(turn))?.[0] || "";
167
+ }
107
168
  return {
108
- async readMetadata() { return structuredClone(record.metadata); },
109
- async writeMetadata(value) { record.metadata = structuredClone(value); },
110
- async updateTurnMetadata(id, patch) {
111
- const turn = turns.get(id);
112
- if (!turn) throw new Error("The conversation turn does not exist.");
113
- turn.metadata = { ...turn.metadata, ...structuredClone(patch) };
114
- },
115
- async listTurnIds() { return [...turns.keys()]; },
116
- async nextTurnId() { return String(Math.max(0, ...[...turns.keys()].map(Number).filter(Number.isSafeInteger)) + 1).padStart(6, "0"); },
117
- async hasMessage(id) {
118
- return Boolean(id) && [...turns.values()].some((turn) => turn.messages.some((message) => message.messageId === id));
119
- },
120
- async readTurn(id) {
121
- const turn = turns.get(id);
122
- if (!turn) return null;
123
- const find = (role) => turn.messages.find((message) => message.role === role) || null;
124
- const thinking = turn.messages.filter((message) => message.role === "thinking");
125
- const commentary = turn.messages.filter((message) => message.role === "commentary");
126
- const activity = [...thinking, ...commentary].sort((a, b) => a.at.localeCompare(b.at));
127
- const system = find("system"), user = find("user"), assistant = find("assistant");
128
- return structuredClone({
129
- turnId: id, user, assistant, thinking, commentary,
130
- ...(system ? { system } : {}), ...(turn.metadata ? { metadata: turn.metadata } : {}),
131
- messages: [system, user, ...activity, assistant].filter(Boolean)
132
- });
133
- },
134
- async appendMessage(id, { turnMetadata, ...message }) {
135
- const turn = turns.get(id) || { messages: [] };
136
- if (turnMetadata) turn.metadata = structuredClone(turnMetadata);
137
- const identity = conversationMessageStorageKey(message);
138
- const existing = identity === null ? -1 : turn.messages.findIndex((previous) =>
139
- conversationMessageStorageKey(previous) === identity);
140
- if (existing < 0) turn.messages.push(structuredClone(message));
141
- else turn.messages[existing] = structuredClone(message);
142
- turns.set(id, turn);
143
- },
144
- async replaceAssistant(id, message) {
145
- if (!id) throw new TypeError("Assistant replacement requires a turn id.");
146
- const turn = turns.get(id) || { messages: [] };
147
- const previous = turn.messages.find((entry) => entry.role === "assistant");
148
- turn.messages = turn.messages.filter((entry) => entry.role !== "assistant");
149
- turn.messages.push({ ...previous, ...structuredClone(message), at: previous?.at || message.at });
150
- turns.set(id, turn);
169
+ record,
170
+ view: {
171
+ async readMetadata() { return structuredClone(draft.metadata); },
172
+ async writeMetadata(value) { draft.metadata = structuredClone(value); },
173
+ async updateTurnMetadata(id, patch) {
174
+ if (!turns().get(id)) throw new Error("The conversation turn does not exist.");
175
+ const turn = ownTurn(id);
176
+ turn.metadata = { ...turn.metadata, ...structuredClone(patch) };
177
+ },
178
+ async listTurnIds() { return [...turns().keys()]; },
179
+ async nextTurnId() { return String(Math.max(0, ...[...turns().keys()].map(Number).filter(Number.isSafeInteger)) + 1).padStart(6, "0"); },
180
+ async hasMessage(id) { return Boolean(messageTurnId(id)); },
181
+ // Optional transaction extension: the turn holding this message ID.
182
+ async findMessageTurnId(id) { return messageTurnId(id); },
183
+ async readTurn(id) {
184
+ const turn = turns().get(id);
185
+ if (!turn) return null;
186
+ const find = (role) => turn.messages.find((message) => message.role === role) || null;
187
+ const thinking = turn.messages.filter((message) => message.role === "thinking");
188
+ const commentary = turn.messages.filter((message) => message.role === "commentary");
189
+ const activity = [...thinking, ...commentary].sort((a, b) => a.at.localeCompare(b.at));
190
+ const system = find("system"), user = find("user"), assistant = find("assistant");
191
+ return structuredClone({
192
+ turnId: id, user, assistant, thinking, commentary,
193
+ ...(system ? { system } : {}), ...(turn.metadata ? { metadata: turn.metadata } : {}),
194
+ messages: [system, user, ...activity, assistant].filter(Boolean)
195
+ });
196
+ },
197
+ async appendMessage(id, { turnMetadata, ...message }) {
198
+ const turn = ownTurn(id);
199
+ if (turnMetadata) turn.metadata = structuredClone(turnMetadata);
200
+ const identity = conversationMessageStorageKey(message);
201
+ const existing = identity === null ? -1 : turn.messages.findIndex((previous) =>
202
+ conversationMessageStorageKey(previous) === identity);
203
+ if (existing < 0) turn.messages.push(structuredClone(message));
204
+ else turn.messages[existing] = structuredClone(message);
205
+ if (message.messageId) added.set(message.messageId, id);
206
+ },
207
+ async replaceAssistant(id, message) {
208
+ if (!id) throw new TypeError("Assistant replacement requires a turn id.");
209
+ const turn = ownTurn(id);
210
+ const previous = turn.messages.find((entry) => entry.role === "assistant");
211
+ turn.messages = turn.messages.filter((entry) => entry.role !== "assistant");
212
+ const replacement = { ...previous, ...structuredClone(message), at: previous?.at || message.at };
213
+ turn.messages.push(replacement);
214
+ if (replacement.messageId) added.set(replacement.messageId, id);
215
+ }
151
216
  }
152
217
  };
153
218
  }
@@ -157,11 +222,17 @@ export function createConversationStorage({ readRecord, writeRecord, deleteRecor
157
222
  const id = key(scope);
158
223
  const previous = pending.get(id) || Promise.resolve();
159
224
  const operation = previous.then(async () => {
160
- const draft = structuredClone(await readRecord(id));
161
- const view = transaction(draft);
162
- const result = await callback(view);
225
+ const draft = transaction(await readSaved(id));
226
+ const result = await callback(draft.view);
227
+ // A failed or partial save leaves the cached record unknown.
228
+ cache.delete(id);
163
229
  if (remove) await deleteRecord(id);
164
- else await writeRecord(id, draft, view);
230
+ else {
231
+ const record = draft.record();
232
+ await writeRecord(id, record, draft.view);
233
+ const version = typeof recordVersion === "function" ? await recordVersion(id) : null;
234
+ if (version != null) cache.set(id, { version, record });
235
+ }
165
236
  return result;
166
237
  });
167
238
  const settled = operation.then(() => undefined, () => undefined);
@@ -175,7 +246,7 @@ export function createConversationStorage({ readRecord, writeRecord, deleteRecor
175
246
  async read(scope, callback) {
176
247
  const id = key(scope);
177
248
  await pending.get(id);
178
- return callback(transaction(structuredClone(await readRecord(id))));
249
+ return callback(transaction(await readSaved(id)).view);
179
250
  },
180
251
  async deleteConversation(scope) {
181
252
  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();
@@ -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", "provider-started", "runtime-ready", "dispatch-started", "native-acknowledged"
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
- assert.deepEqual(timings.slice(-2).map(event => event.stage), ["receipt-persisted", "accepted"]);
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
- assert.deepEqual(stages.slice(0, 8), ["received", "preparation-started", "provider-started", "runtime-ready",
1269
- "dispatch-started", "native-acknowledged", "receipt-persisted", "accepted"]);
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
  });
@@ -81,6 +81,15 @@ test("message adaptation retains progress, attachments, system actions and SQL m
81
81
  assert.equal(turns[2].assistant.sql, "select 1");
82
82
  });
83
83
 
84
+ test("record storage carries adapter fields it does not manage through writes", async () => {
85
+ let record = { turns: new Map(), metadata: {}, rewound: [{ turnId: "000001" }] };
86
+ const storage = createConversationStorage({ readRecord: () => record, writeRecord: (_scope, next) => { record = next; } });
87
+ await storage.write("conversation", transaction => transaction.appendMessage("000002",
88
+ { role: "user", at: "2026-10-01T01:00:00.000Z", text: "Hello", messageId: "m1" }));
89
+ assert.deepEqual(record.rewound, [{ turnId: "000001" }]);
90
+ assert.equal(record.turns.get("000002").messages[0].text, "Hello");
91
+ });
92
+
84
93
  test("record storage preserves original role, timestamp and message-id overwrite identity", async () => {
85
94
  let record = { turns: new Map(), metadata: {} };
86
95
  const storage = createConversationStorage({ readRecord: () => record, writeRecord: (_scope, next) => { record = next; } });
@@ -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
- assert.deepEqual(stages.slice(0, 8), ["received", "preparation-started", "provider-started", "runtime-ready", "dispatch-started",
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
  });