@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 +6 -0
- package/examples/conversation/package.json +3 -3
- package/package.json +2 -2
- package/src/server/conversation/conversationDelivery.js +6 -8
- package/src/server/conversation/conversationEvents.js +20 -1
- package/src/server/conversation/conversationHandles.js +5 -3
- package/src/server/conversation/conversationReads.js +25 -8
- package/src/server/conversation/providers/api.js +2 -0
- package/src/server/conversation/storage.js +8 -1
- package/src/server/conversation/transcript.js +1 -1
- package/src/shared/assistantResource.js +9 -3
- package/test/apiConversationDriver.test.js +14 -0
- package/test/assistantResource.test.js +15 -0
- package/test/conversationSession.test.js +44 -0
- package/test/conversationStreaming.test.js +25 -0
- package/test/fileConversationStorage.test.js +12 -0
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.
|
|
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.
|
|
18
|
-
"@jskit-ai/connectors-catalog": "0.2.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
185
|
-
const existing =
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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 =
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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(
|
|
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
|
+
});
|