@jskit-ai/assistant-core 0.2.37 → 0.2.39

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - `createConversationStorage`'s `write` saves nothing when its callback called no write method: the adapter's `writeRecord` does not run, so file storage no longer rewrites and flushes the whole conversation file, or creates an empty one for a missing conversation, when a write only looked (a delivery check, a message already saved), and the saved record stays cached.
6
+ - Checking or recovering one message's delivery (`inspectDelivery`, and a send that finds its message already saved) finds that message's turn by its id instead of reading and cloning every turn of the conversation to find it.
7
+ - With completed envelopes, a streamed turn's saved completed-envelope marker is read once per turn instead of once per delta: the marker is written with the turn's authored message and never changes, and a reply's hundreds of deltas each opened a storage read. A turn not saved yet is read again until it is.
8
+ - **Breaking.** `assistantContract`'s `conversationsList` items have no `metadata`: a list shows a conversation's title, status and times, and the runtime metadata, which held the system prompt, every tool's schema and a note per message ever written, made each history page grow with every message. `conversationMessagesList` still answers the conversation with its metadata. Upgrading: a client that read `metadata` from a list item reads it from the conversation's messages read.
9
+ - A conversation handle's `wait(query)` takes the query `read(query)` does, and `wait({ turnId })` answers only that turn (an empty `conversationLog` when it has no messages, or is a hidden completed-envelope turn), reading that turn and the latest one instead of reading and cloning every turn of the conversation.
10
+ - The API driver gives `runLoop` its model's own turn limits (`turnLimits`), so inside one long turn old action results give way before a smaller model's window overflows; they waited for the default limits, sized for a million-token window.
5
11
  - A Bash command that Claude steering moves to the background keeps its own time limit: its `timeout`, or Claude's two-minute default, counted from when it started. Moved by the `background_tasks` control (Ctrl+B) so a person's message reaches Claude, the command used to lose that limit, so a wait loop that never ended held the turn in its background phase (and the host's Save behind it) until it failed half an hour later. When its limit passes, the shared Claude turn stops its task with the `stop_task` control and publishes the `steered_command_limit` diagnostic (`steered_command_stop_failed` when Claude refuses it); Claude answers the task notification and the turn settles as after a foreground timeout. Commands Claude starts with `run_in_background`, and subagents, keep running as before.
6
12
  - Claude's model list is kept once per account in the server process instead of once per account-query owner, as plan usage already is: an owner the host creates again (a restarted service, a reopened project) answers `readCatalogue` from the list another owner read, without starting a tool-free Claude, while the account's credentials and Claude Code are unchanged. A credentials change (sign-in, sign-out, token refresh) or a Claude Code update still reads it again, and an owner's `invalidate()` drops the list of every account it used, for every owner.
7
13
  - Test fixture only: the responsive assistant fixture's avatar is drawn in the theme's `primary` and `on-primary` roles instead of a raw `#6750a4` and `white`, so it follows the dark scheme like the rest of the conversation. A package test runs `jskit check m3`'s raw-colour rule over the fixture, which the check itself skips with every `fixtures/` folder.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.37",
11
+ "@jskit-ai/assistant-core": "0.2.39",
12
12
  "@jskit-ai/http-runtime": "0.2.18",
13
13
  "@jskit-ai/kernel": "0.2.18",
14
14
  "@jskit-ai/material-web": "0.1.5",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.2.23",
18
- "@jskit-ai/connectors-catalog": "0.2.23"
17
+ "@jskit-ai/connectors-core": "0.2.25",
18
+ "@jskit-ai/connectors-catalog": "0.2.25"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.37",
3
+ "version": "0.2.39",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "jskit test"
@@ -51,7 +51,7 @@
51
51
  "@ai-sdk/perplexity": "^4.0.43",
52
52
  "@ai-sdk/togetherai": "^3.0.49",
53
53
  "@ai-sdk/xai": "^4.0.58",
54
- "@jskit-ai/agent": "0.1.2",
54
+ "@jskit-ai/agent": "0.1.3",
55
55
  "@jskit-ai/resource-core": "0.1.209",
56
56
  "@openrouter/ai-sdk-provider": "^3.0.0",
57
57
  "ai": "^7.0.152",
@@ -181,8 +181,8 @@ function createConversationDelivery({ storage, transcript, events, entries, read
181
181
  if (entry.active && !allowActive) throw failure("Wait for the current submission before checking delivery.", "conversation_busy", 409, { messageCode: "assistant.conversation.waitForSubmission" });
182
182
  if (entry.storageFailure) throw entry.storageFailure;
183
183
  if (entry.executionFailure) throw entry.executionFailure;
184
- const history = await reads.readTranscript(entry);
185
- const existing = history.find(turn => submittedMessage(turn)?.messageId === messageId);
184
+ // The message's own turn, found by its id: never a read of every turn.
185
+ const existing = await reads.findSubmittedTurn(entry, messageId);
186
186
  const receipt = existing && { status: "accepted", messageId, turnId: existing.turnId, duplicate: true };
187
187
  // Turns written before the first runtime segment carry no runtime metadata.
188
188
  const existingRuntime = existing?.metadata?.runtime;
@@ -191,7 +191,7 @@ function createConversationDelivery({ storage, transcript, events, entries, read
191
191
  if (existing && (existingRuntime?.segmentId !== entry.segmentId || existingRuntime.supersededBy ||
192
192
  existingRuntime.status === "complete" && !goalRequest)) return receipt;
193
193
  if (goalMessageId && goalMessageId !== messageId) {
194
- const owner = history.find(turn => submittedMessage(turn)?.messageId === goalMessageId);
194
+ const owner = await reads.findSubmittedTurn(entry, goalMessageId);
195
195
  const ownerRuntime = owner?.metadata?.runtime;
196
196
  if (ownerRuntime?.goalMessageId !== goalMessageId || ownerRuntime.segmentId !== entry.segmentId) throw new Error("The goal command for this native continuation is missing.");
197
197
  const result = await inspectDelivery(entry, context, goalMessageId);
@@ -227,8 +227,7 @@ function createConversationDelivery({ storage, transcript, events, entries, read
227
227
  if (!observation.accepted) return { ...(receipt || { status: "unknown", messageId }),
228
228
  ...(observation.recoveryLimitation ? { recoveryLimitation: observation.recoveryLimitation } : {}) };
229
229
  if (entry.driver.canonicalTranscript) {
230
- const latest = await reads.readTranscript(entry);
231
- const saved = latest.find(turn => submittedMessage(turn)?.messageId === messageId);
230
+ const saved = await reads.findSubmittedTurn(entry, messageId);
232
231
  if (!saved) throw new Error("The native receipt did not persist its authored message.");
233
232
  const state = saved.metadata?.runtime;
234
233
  if (state?.segmentId !== entry.segmentId) throw new Error("The native receipt belongs to another conversation segment.");
@@ -261,7 +260,7 @@ function createConversationDelivery({ storage, transcript, events, entries, read
261
260
  const native = { ...observation, messageId };
262
261
  const results = [];
263
262
  const id = messageId;
264
- let turn = history.find(turn => submittedMessage(turn)?.messageId === id);
263
+ let turn = existing;
265
264
  if (turn?.metadata?.runtime?.supersededBy || turn && turn.metadata?.runtime?.segmentId !== entry.segmentId) {
266
265
  throw new Error("Native history refers to a turn outside the active conversation segment.");
267
266
  }
@@ -276,7 +275,6 @@ function createConversationDelivery({ storage, transcript, events, entries, read
276
275
  ...(input.completedEnvelope === true ? { completedEnvelope: true } : {}),
277
276
  ...(typeof input.nativeToolAttempt === "boolean" ? { nativeToolAttempt: input.nativeToolAttempt } : {}) } } });
278
277
  turn = await transaction.readTurn(turnId);
279
- history.push(turn);
280
278
  }
281
279
  const runtime = turn.metadata.runtime;
282
280
  if (runtime.nativeTurnId && native.nativeTurnId && runtime.nativeTurnId !== native.nativeTurnId) throw new Error("Native history changed the accepted turn's identity.");
@@ -349,7 +347,7 @@ function createConversationDelivery({ storage, transcript, events, entries, read
349
347
  if (delivered?.notSent) return notSentDeliveryReceipt(messageId);
350
348
  if (!delivered?.delivered) throw deliveryFailure(delivered);
351
349
  }
352
- const row = (await reads.readTranscript(entry)).find(turn => submittedMessage(turn)?.messageId === messageId);
350
+ const row = await reads.findSubmittedTurn(entry, messageId);
353
351
  if (row) return { status: "accepted", messageId, turnId: row.turnId, duplicate: true };
354
352
  const threadId = state.request?.messageId === messageId ? state.request.threadId : conversationNativeIdentity(state);
355
353
  if (!threadId) return { status: "unknown", messageId };
@@ -29,6 +29,25 @@ function serial(entry, callback) {
29
29
  * @param {boolean} dependencies.completedEnvelope
30
30
  */
31
31
  function createConversationEvents({ authorize, isClosed, storage, streams, completedEnvelope }) {
32
+ // Each entry's last saved turn whose completed-envelope marker was read. A turn's marker is written with its authored
33
+ // message and never changes, so the hundreds of deltas of one reply read it once, not once each.
34
+ const envelopeMarks = new WeakMap();
35
+
36
+ /**
37
+ * Whether the saved turn carries the completed-envelope marker; false while it is not saved.
38
+ * @param {ConversationEntry} entry
39
+ * @param {string} turnId
40
+ */
41
+ async function savedEnvelopeMark(entry, turnId) {
42
+ const known = envelopeMarks.get(entry);
43
+ if (known?.turnId === turnId) return known.marked;
44
+ const turn = await storage.read(entry.id, transaction => transaction.readTurn(turnId));
45
+ if (!turn) return false;
46
+ const marked = isCompletedEnvelopeTurn(turn);
47
+ envelopeMarks.set(entry, { turnId, marked });
48
+ return marked;
49
+ }
50
+
32
51
  /**
33
52
  * Send and its immediate admission share one authorization. Dispatch, receipt admission and
34
53
  * every other operation authorize again.
@@ -99,7 +118,7 @@ function createConversationEvents({ authorize, isClosed, storage, streams, compl
99
118
  let marked = isCompletedEnvelopeTurn(payload.patch?.turn) ||
100
119
  request?.completedEnvelope === true && request.input.origin === "application";
101
120
  if (!marked && payload.turnId) {
102
- try { marked = await storage.read(entry.id, async transaction => isCompletedEnvelopeTurn(await transaction.readTurn(payload.turnId))); }
121
+ try { marked = await savedEnvelopeMark(entry, payload.turnId); }
103
122
  catch { /* An unavailable receipt cannot establish presentation custody. */ }
104
123
  }
105
124
  if (!marked && payload.type === "message") marked =
@@ -142,14 +142,16 @@ function createConversationHandles({ entries, storage, defaultHost, events, read
142
142
  return entry.provider.inspectAdmission({ messageId, threadId, context, representation });
143
143
  });
144
144
  },
145
- async wait(input) {
145
+ // Waits for the conversation's work to settle and reads it as read(query) does: `{ turnId }`
146
+ // reads only that turn, so a caller that waits on its own turn never reads the whole transcript.
147
+ async wait(query) {
146
148
  await events.access(context, id, "read", entry);
147
149
  if (entry.conversation?.commands) throw failure("This conversation uses its original read and subscription operations.", "conversation_original_read_only", 400, { messageCode: "assistant.conversation.originalReadOnly" });
148
- if (entry.conversation?.native?.scoped) return entry.provider.waitNative({ input, context });
150
+ if (entry.conversation?.native?.scoped) return entry.provider.waitNative({ input: query, context });
149
151
  await entry.pending;
150
152
  await entry.active?.done;
151
153
  await events.access(context, id, "read", entry);
152
- return reads.snapshot(entry, await entries.readState(entry));
154
+ return reads.snapshot(entry, await entries.readState(entry), query);
153
155
  },
154
156
  // Cancel of one unsent or unconfirmed message always records it; an
155
157
  // application conversation supplies its own operation for that.
@@ -1,5 +1,6 @@
1
1
  import { codexAppServerTurnStateFromAgentRun } from "./codexTurn.js";
2
2
  import { failure, stateErrorFields } from "./conversationRefusals.js";
3
+ import { hasMessages, isCompletedEnvelopeTurn } from "./transcript.js";
3
4
 
4
5
  /** @typedef {import("./conversationEntries.js").ConversationEntry} ConversationEntry */
5
6
  /** @typedef {import("./conversationEntries.js").ConversationState} ConversationState */
@@ -85,6 +86,17 @@ function createConversationReads({ storage, transcript, streams, attachmentsOffe
85
86
  return (await readTranscript(entry)).find(turn => turn.turnId === turnId) || null;
86
87
  }
87
88
 
89
+ /**
90
+ * One saved turn by its id, or null, without reading the rest of the transcript.
91
+ * @param {ConversationEntry} entry
92
+ * @param {string} turnId
93
+ */
94
+ async function readTurn(entry, turnId) {
95
+ if (entry.conversation) return readBoundTurn(entry, turnId);
96
+ const turn = await storage.read(entry.id, transaction => transaction.readTurn(turnId));
97
+ return hasMessages(turn) ? turn : null;
98
+ }
99
+
88
100
  /** @param {ConversationEntry} entry */
89
101
  async function lastTranscriptTurn(entry) {
90
102
  const page = entry.conversation && !offersPages(entry) ? null
@@ -143,20 +155,24 @@ function createConversationReads({ storage, transcript, streams, attachmentsOffe
143
155
  }
144
156
 
145
157
  /**
146
- * What a reader sees of a conversation from its saved state: a page of its transcript, or all
147
- * of it, with the current turn's status, phase, error and live stream.
158
+ * What a reader sees of a conversation from its saved state: a page of its transcript, one turn
159
+ * of it (`query.turnId`), or all of it, with the current turn's status, phase, error and live stream.
148
160
  * @param {ConversationEntry} entry
149
161
  * @param {ConversationState} state
150
162
  * @param {object} [query]
151
163
  */
152
164
  async function snapshot(entry, state, query) {
153
- const page = query ? await readTranscriptPage(entry, query) : null;
165
+ const oneTurn = query?.turnId !== undefined;
166
+ const page = oneTurn ? { conversationLog: [await readTurn(entry, query.turnId)]
167
+ .filter(turn => turn && !(completedEnvelope && isCompletedEnvelopeTurn(turn))) }
168
+ : query ? await readTranscriptPage(entry, query) : null;
154
169
  const conversationLog = page ? page.conversationLog
155
170
  : completedEnvelope ? await transcript.readConversationLog(entry.id, { presentation: true }) : await readTranscript(entry);
156
- // Browsing an older page does not change the current turn's control/error state.
157
- const latest = completedEnvelope || query?.beforeTurnId
158
- ? entry.conversation ? (await readTranscriptPage(entry, { limit: 1 })).conversationLog
159
- : (await transcript.readConversationLogPage(entry.id, { limit: 1 })).conversationLog : conversationLog;
171
+ // Browsing an older page, or reading one turn, does not change the current turn's control/error state.
172
+ const latest = oneTurn ? [await lastTranscriptTurn(entry)].filter(Boolean)
173
+ : completedEnvelope || query?.beforeTurnId
174
+ ? entry.conversation ? (await readTranscriptPage(entry, { limit: 1 })).conversationLog
175
+ : (await transcript.readConversationLogPage(entry.id, { limit: 1 })).conversationLog : conversationLog;
160
176
  const last = latest.at(-1)?.metadata?.runtime;
161
177
  const savedRequest = state.request;
162
178
  const nativeTurn = state.nativeTurn || (entry.driver.canonicalTranscript && state.binding?.codexAppServerRun
@@ -182,7 +198,7 @@ function createConversationReads({ storage, transcript, streams, attachmentsOffe
182
198
  goal: entry.goal || null,
183
199
  pendingRequest,
184
200
  conversationLog: conversationLog.filter(turn => !turn.metadata?.runtime?.supersededBy),
185
- ...(page ? { pagination: page.pagination } : {}),
201
+ ...(page?.pagination ? { pagination: page.pagination } : {}),
186
202
  streaming: completedEnvelope && !entry.conversation ? { ...streaming,
187
203
  messages: streaming.messages.filter(message => !(message.origin === "application" && message.completedEnvelope === true)) } : streaming
188
204
  };
@@ -195,6 +211,7 @@ function createConversationReads({ storage, transcript, streams, attachmentsOffe
195
211
  findSubmittedTurn,
196
212
  readBoundTurn,
197
213
  lastTranscriptTurn,
214
+ readTurn,
198
215
  readNativeResult,
199
216
  logicalSnapshot,
200
217
  snapshot
@@ -264,6 +264,8 @@ export function createApiConversationDriver({ connections, apiClientFactory, fet
264
264
  const pass = async history => {
265
265
  const result = await runLoop({ model, name: NAME, instructions: configuration.systemPrompt, history, tools: engineTools,
266
266
  summary: earlier, state: stateNote(), maxSteps: maximumSteps, onText, onStep, signal: stopped, clean: text => stripNotes(unsaid(text)),
267
+ // A long turn's old results give way within this model's window, not the largest one's.
268
+ turnLimits: window.turn,
267
269
  // With nothing done yet, a failed call is the reply's error, not an account.
268
270
  throwIf: (_error, steps) => !step && !steps.length,
269
271
  // A configured effort reaches every provider the standard way.
@@ -152,9 +152,12 @@ export function createConversationStorage({ readRecord, writeRecord, deleteRecor
152
152
  function transaction(saved) {
153
153
  const { index: _index, ...draft } = saved;
154
154
  const added = new Map();
155
+ // Whether any write method ran: a transaction that only read saves nothing.
156
+ let changed = false;
155
157
  let ownTurns = false;
156
158
  const owned = new Set();
157
159
  function ownTurn(id) {
160
+ changed = true;
158
161
  if (!ownTurns) {
159
162
  draft.turns = new Map(draft.turns);
160
163
  ownTurns = true;
@@ -203,9 +206,10 @@ export function createConversationStorage({ readRecord, writeRecord, deleteRecor
203
206
  }
204
207
  return {
205
208
  record,
209
+ changed: () => changed,
206
210
  view: {
207
211
  async readMetadata() { return structuredClone(draft.metadata); },
208
- async writeMetadata(value) { draft.metadata = structuredClone(value); },
212
+ async writeMetadata(value) { changed = true; draft.metadata = structuredClone(value); },
209
213
  async updateTurnMetadata(id, patch) {
210
214
  if (!turns().get(id)) throw new Error("The conversation turn does not exist.");
211
215
  const turn = ownTurn(id);
@@ -229,6 +233,7 @@ export function createConversationStorage({ readRecord, writeRecord, deleteRecor
229
233
  async writeSummary({ text, throughTurnId }) {
230
234
  if (typeof text !== "string" || !text.trim()) throw new TypeError("A conversation summary requires text.");
231
235
  if (!turns().get(throughTurnId)) throw new Error("The conversation turn does not exist.");
236
+ changed = true;
232
237
  draft.summary = { text, throughTurnId };
233
238
  },
234
239
  async appendMessage(id, { turnMetadata, ...message }) {
@@ -261,6 +266,8 @@ export function createConversationStorage({ readRecord, writeRecord, deleteRecor
261
266
  const operation = previous.then(async () => {
262
267
  const draft = transaction(await readSaved(id));
263
268
  const result = await callback(draft.view);
269
+ // A write that changed nothing leaves the saved record, and its cache, as they are.
270
+ if (!remove && !draft.changed()) return result;
264
271
  // A failed or partial save leaves the cached record unknown.
265
272
  cache.delete(id);
266
273
  if (remove) await deleteRecord(id);
@@ -163,7 +163,7 @@ export function createConversationTranscript({ storage, clock = currentDate, app
163
163
  });
164
164
  }
165
165
 
166
- function hasMessages(turn) {
166
+ export function hasMessages(turn) {
167
167
  return Boolean(turn && (turn.system || turn.user || turn.assistant || turn.commentary?.length || turn.thinking?.length));
168
168
  }
169
169
 
@@ -74,7 +74,7 @@ const conversationsListQuerySchema = createSchema({
74
74
  }
75
75
  });
76
76
 
77
- const conversationRecordSchema = createSchema({
77
+ const conversationRecordFields = {
78
78
  id: {
79
79
  type: "string",
80
80
  required: true,
@@ -146,7 +146,13 @@ const conversationRecordSchema = createSchema({
146
146
  required: true,
147
147
  minLength: 1
148
148
  }
149
- });
149
+ };
150
+
151
+ const conversationRecordSchema = createSchema(conversationRecordFields);
152
+
153
+ // A list's conversation: the record without its runtime metadata, which only a read of the conversation needs.
154
+ const { metadata: _metadata, ...conversationListItemFields } = conversationRecordFields;
155
+ const conversationListItemSchema = createSchema(conversationListItemFields);
150
156
 
151
157
  const messageRecordSchema = createSchema({
152
158
  id: {
@@ -277,7 +283,7 @@ const assistantContract = defineApiContract({
277
283
  conversationsList: {
278
284
  method: "GET",
279
285
  query: conversationsListQuerySchema,
280
- output: createCursorListValidator(createSchemaDefinition(conversationRecordSchema, "replace"))
286
+ output: createCursorListValidator(createSchemaDefinition(conversationListItemSchema, "replace"))
281
287
  },
282
288
  conversationMessagesList: {
283
289
  method: "GET",
@@ -254,3 +254,17 @@ test("a short history is read whole with its stored summary, and nothing is save
254
254
  assert.match(f.requests[0].messages[0].content, /Summary of the earlier part of this conversation[^]*Earlier: hello\./u);
255
255
  assert.deepEqual(f.requests[0].messages.slice(1, -1).map(message => message.content), ["Hi", "Hello", "Next question"]);
256
256
  });
257
+
258
+ // Inside one long turn, old action results give way to a note once the request nears the model's
259
+ // own window, not the million-token one the engine's defaults are sized for: sized for that, a
260
+ // smaller model's request overflowed long before anything was left out.
261
+ test("a long turn's old action results give way within the model's window", async () => {
262
+ const f = fixture({ limits: { contextTokens: 10_000 },
263
+ reply: round => round <= 22 ? { call: "action_execute", input: { page: round } } : { text: "Read every page." },
264
+ execute: (_input, count) => ({ ok: true, result: { page: count, rows: "row ".repeat(1000) } }) });
265
+ await f.run("Read every page of the report.");
266
+ assert.equal(f.executions.length, 22);
267
+ const last = JSON.stringify(f.streamed.at(-1).messages);
268
+ assert.match(last, /was left out to save room/u);
269
+ assert.match(last, /\\"page\\":22/u, "the newest results stay");
270
+ });
@@ -70,3 +70,18 @@ test("assistant conversation message params accept numeric path strings", async
70
70
  assert.equal(parsed.ok, true);
71
71
  assert.deepEqual(parsed.value, { conversationId: 2 });
72
72
  });
73
+
74
+ // A list shows each conversation without its runtime metadata (the system prompt, every tool's
75
+ // schema and a note per message), which grows with every message; a read of the conversation keeps it.
76
+ test("a conversation list item carries no runtime metadata, and a read of the conversation does", async () => {
77
+ const item = { id: "1", workspaceId: null, title: "Trip", createdByUserId: "7", status: "active", provider: "openai",
78
+ model: "gpt-4.1", surfaceId: "home", startedAt: "2026-03-16T10:00:00.000Z", endedAt: null, messageCount: 2,
79
+ createdAt: "2026-03-16T10:00:00.000Z", updatedAt: "2026-03-16T10:01:00.000Z" };
80
+ const listed = await validateOperationSection({ operation: assistantContract.operations.conversationsList,
81
+ section: "output", value: { items: [item], nextCursor: null } });
82
+ assert.equal(listed.ok, true);
83
+ assert.equal(Object.hasOwn(listed.value.items[0], "metadata"), false);
84
+ const read = await validateOperationSection({ operation: assistantContract.operations.conversationMessagesList,
85
+ section: "output", value: { conversation: item, entries: [], page: 1, pageSize: 200, total: 0, totalPages: 1 } });
86
+ assert.equal(read.ok, false, "a conversation read still requires its metadata");
87
+ });
@@ -1429,3 +1429,47 @@ test("native settings authorize their scope and reject replaced or closed host p
1429
1429
  await assert.rejects(runtime.updateNativeConversationSettings(request), { code: "conversation_handle_closed" });
1430
1430
  assert.deepEqual([acquisitions, updates], [1, 1]);
1431
1431
  });
1432
+
1433
+ // A caller that waits on its own turn reads that turn, never every turn of the conversation: the
1434
+ // whole transcript was read and cloned at the end of every chat turn to keep one.
1435
+ test("waiting on one turn reads that turn, not the whole transcript", async t => {
1436
+ const memory = createMemoryConversationStorage();
1437
+ const turnReads = [];
1438
+ const storage = { ...memory, read: (scope, operation) => memory.read(scope, transaction =>
1439
+ operation({ ...transaction, readTurn: id => { turnReads.push(id); return transaction.readTurn(id); } })) };
1440
+ const f = await fixture(t, { storage });
1441
+ for (const [messageId, text] of [["m1", "First"], ["m2", "Second"], ["m3", "Third"]]) {
1442
+ await f.conversation.send({ messageId, text });
1443
+ await f.conversation.wait();
1444
+ }
1445
+ const { turnId } = await f.conversation.send({ messageId: "m4", text: "Fourth" });
1446
+ await f.conversation.wait();
1447
+ turnReads.length = 0;
1448
+ const result = await f.conversation.wait({ turnId });
1449
+ assert.deepEqual(result.conversationLog.map(turn => turn.turnId), [turnId]);
1450
+ assert.equal(result.conversationLog[0].assistant.text, "Hello there.");
1451
+ assert.equal(result.status, "ready");
1452
+ assert.equal(result.pagination, undefined);
1453
+ assert.deepEqual([...new Set(turnReads)], [turnId], "only the turn waited on, which is also the latest, is read");
1454
+ assert.deepEqual((await f.conversation.wait({ turnId: "000001" })).conversationLog.map(turn => turn.user.text), ["First"]);
1455
+ assert.deepEqual((await f.conversation.wait({ turnId: "999999" })).conversationLog, []);
1456
+ });
1457
+
1458
+ // Checking one message's delivery finds that message's turn by its id; it read and cloned every
1459
+ // turn of the conversation to find it.
1460
+ test("checking one message's delivery reads that message's turn, not the whole transcript", async t => {
1461
+ const memory = createMemoryConversationStorage();
1462
+ const turnReads = [];
1463
+ const storage = { ...memory, read: (scope, operation) => memory.read(scope, transaction =>
1464
+ operation({ ...transaction, readTurn: id => { turnReads.push(id); return transaction.readTurn(id); } })) };
1465
+ const f = await fixture(t, { storage });
1466
+ for (const [messageId, text] of [["m1", "First"], ["m2", "Second"], ["m3", "Third"], ["m4", "Fourth"]]) {
1467
+ await f.conversation.send({ messageId, text });
1468
+ await f.conversation.wait();
1469
+ }
1470
+ turnReads.length = 0;
1471
+ const receipt = await f.conversation.inspectDelivery({ messageId: "m2" });
1472
+ assert.equal(receipt.status, "accepted");
1473
+ assert.equal(receipt.turnId, "000002");
1474
+ assert.deepEqual([...new Set(turnReads)], ["000002"]);
1475
+ });
@@ -4,6 +4,7 @@ import { createConversationStreams } from "../src/server/conversation/streams.js
4
4
  import { classifyCodexAppServerEvent } from "../src/server/conversation/codexEvents.js";
5
5
  import { createOpenCodeServerClient, openCodeAssistantMessageText } from "../src/server/conversation/openCodeClient.js";
6
6
  import { mergeConversationStream } from "../src/shared/conversation/streaming.js";
7
+ import { createConversationEvents } from "../src/server/conversation/conversationEvents.js";
7
8
 
8
9
  test("Codex chunks retain whitespace and item identity; completed items reject late chunks", () => {
9
10
  const streams = createConversationStreams();
@@ -256,3 +257,27 @@ test("live message metadata retains its captured attribution across deltas witho
256
257
  const other = streams.update("two", { ...input, metadata: { model: { id: "other" } } });
257
258
  assert.equal(other.messages[0].metadata.model.id, "other");
258
259
  });
260
+
261
+ // A completed-envelope runtime reads a streamed turn's saved marker once, not once for each of a
262
+ // reply's hundreds of deltas: the marker is written with the turn's authored message and never changes.
263
+ test("a reply's streamed deltas read their turn's completed-envelope marker once", async () => {
264
+ const reads = [];
265
+ const turns = new Map([["000001", { turnId: "000001", user: { text: "Hi" }, metadata: { runtime: { origin: "user" } } }],
266
+ ["000002", { turnId: "000002", system: { text: "Wake" }, metadata: { runtime: { origin: "application", completedEnvelope: true } } }]]);
267
+ const storage = { read: (_id, operation) => operation({ readTurn: async turnId => { reads.push(turnId); return structuredClone(turns.get(turnId) || null); } }) };
268
+ const events = createConversationEvents({ authorize: () => true, isClosed: () => false, storage, streams: null, completedEnvelope: true });
269
+ const heard = [];
270
+ const entry = { id: "chat", disposed: false, active: { requests: new Map() },
271
+ listeners: new Set([{ context: {}, authorizedAt: Date.now(), listener: event => { heard.push(event); } }]) };
272
+ const delta = (turnId, text) => ({ type: "message", role: "assistant", status: "inProgress", turnId, messageId: `reply-${turnId}`, text });
273
+ for (let index = 1; index <= 50; index++) await events.emit(entry, delta("000001", "word ".repeat(index)));
274
+ assert.deepEqual(reads, ["000001"]);
275
+ assert.equal(heard.length, 50);
276
+ assert.equal(heard.some(event => event.completedEnvelope), false);
277
+ for (let index = 1; index <= 5; index++) await events.emit(entry, delta("000002", "raw".repeat(index)));
278
+ assert.deepEqual(reads, ["000001", "000002"]);
279
+ assert.equal(heard.slice(50).every(event => event.completedEnvelope === true), true, "a marked turn's deltas stay marked");
280
+ await events.emit(entry, delta("000003", "unsaved"));
281
+ await events.emit(entry, delta("000003", "unsaved still"));
282
+ assert.deepEqual(reads, ["000001", "000002", "000003", "000003"], "a turn not saved yet is read again");
283
+ });
@@ -109,3 +109,15 @@ test("saved conversations are parsed once until they change and look messages up
109
109
  await f.storage.read("cached", transaction => transaction.appendMessage("000001", { role: "assistant", text: "Read only", at: "2026-01-01T00:00:03Z" }));
110
110
  assert.equal((await f.transcript.readConversationLog("cached"))[0].assistant, null);
111
111
  });
112
+
113
+ test("a write that changes nothing leaves the saved file alone", async (t) => {
114
+ const f = await fixture(t);
115
+ await f.transcript.writeConversationUserMessage("unchanged", { text: "First", messageId: "first", at: "2026-01-01T00:00:00Z" });
116
+ const [file] = (await readdir(f.directory)).filter(name => name.endsWith(".json"));
117
+ const before = await stat(join(f.directory, file), { bigint: true });
118
+ assert.equal(await f.storage.write("unchanged", transaction => transaction.findMessageTurnId("first")), "000001");
119
+ const after = await stat(join(f.directory, file), { bigint: true });
120
+ assert.deepEqual([after.ino, after.mtimeNs], [before.ino, before.mtimeNs], "an unchanged conversation is not saved again");
121
+ assert.deepEqual(await f.storage.write("absent", transaction => transaction.listTurnIds()), []);
122
+ assert.deepEqual((await readdir(f.directory)).filter(name => name.endsWith(".json")), [file], "looking at a missing conversation does not create it");
123
+ });