@jskit-ai/assistant-core 0.2.9 → 0.2.11

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,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.9",
12
- "@jskit-ai/http-runtime": "0.2.4",
13
- "@jskit-ai/kernel": "0.2.4",
11
+ "@jskit-ai/assistant-core": "0.2.11",
12
+ "@jskit-ai/http-runtime": "0.2.5",
13
+ "@jskit-ai/kernel": "0.2.5",
14
14
  "@mdi/js": "^7.4.47",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.2.5",
18
- "@jskit-ai/connectors-catalog": "0.2.5"
17
+ "@jskit-ai/connectors-core": "0.2.6",
18
+ "@jskit-ai/connectors-catalog": "0.2.6"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
package/locales/en.json CHANGED
@@ -333,6 +333,7 @@
333
333
  "compactWhileWorking": "Claude is still working. Wait for it to finish before freeing its context.",
334
334
  "noActiveTurn": "Claude has no active turn for this message.",
335
335
  "steeringUnavailable": "This Claude turn cannot accept steering now.",
336
+ "settingsChangeWhileWorking": "Stop the current Claude turn before changing its settings.",
336
337
  "notConnected": "Claude Code is not connected.",
337
338
  "accountStatusInvalid": "Claude Code returned an invalid account status."
338
339
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -51,8 +51,8 @@
51
51
  "@ai-sdk/togetherai": "^3.0.49",
52
52
  "@ai-sdk/xai": "^4.0.58",
53
53
  "@jskit-ai/agent": "0.1.0",
54
- "@jskit-ai/resource-core": "0.1.195",
55
- "@jskit-ai/resource-crud-core": "0.1.195",
54
+ "@jskit-ai/resource-core": "0.1.196",
55
+ "@jskit-ai/resource-crud-core": "0.1.196",
56
56
  "@mdi/js": "^7.4.47",
57
57
  "@openrouter/ai-sdk-provider": "^3.0.0",
58
58
  "ai": "^7.0.152",
@@ -61,8 +61,8 @@
61
61
  "ws": "^8.20.1"
62
62
  },
63
63
  "peerDependencies": {
64
- "@jskit-ai/http-runtime": "0.2.4",
65
- "@jskit-ai/kernel": "0.2.4",
64
+ "@jskit-ai/http-runtime": "0.2.5",
65
+ "@jskit-ai/kernel": "0.2.5",
66
66
  "vue": "^3.5.13",
67
67
  "vuetify": "^4.0.0"
68
68
  },
@@ -56,9 +56,11 @@ export function createClaudePromptDelivery({
56
56
  }
57
57
  let native;
58
58
  try {
59
- if (!command && entry.main && entry.turn?.active && entry.process) {
60
- // Main steering keeps the active instruction installation, just like
61
- // command steering. Refresh guidance on the next idle admission.
59
+ if (!command && entry.turn?.active && entry.process) {
60
+ // Steering keeps the active instruction installation, just like command
61
+ // steering, in Main and in every other conversation: guidance changed
62
+ // by the running work (a repair editing the project's Genesis files)
63
+ // applies at the next idle admission and never refuses the message.
62
64
  // Account replacement must still confirm cleanup before any new send.
63
65
  if (preparation.account) await bindAccount(entry);
64
66
  native = entry.process || await ensureProcess(entry, input);
@@ -163,7 +163,14 @@ export function codexAppServerProcessedResultEvent(session = {}, threadId = "",
163
163
  )) || null;
164
164
  }
165
165
 
166
- export function codexAppServerTurnStateFromAgentRun(run = {}, normalizeRunState = normalizeCodexAppServerRunState) {
166
+ /**
167
+ * The turn a saved Codex run reports. No saved run (null or undefined) is no turn: it is idle. A saved run
168
+ * whose state is not written yet is starting, which counts as active.
169
+ * @param {object | null | undefined} run
170
+ * @param {(state: unknown) => string} [normalizeRunState]
171
+ */
172
+ export function codexAppServerTurnStateFromAgentRun(run, normalizeRunState = normalizeCodexAppServerRunState) {
173
+ if (run == null) return codexAppServerIdleTurnState();
167
174
  const runState = normalizeRunState(run.state);
168
175
  const active = codexAppServerRunStateIsActive(runState);
169
176
  const state = runState === CODEX_APP_SERVER_RUN_STATE.FINALIZING
@@ -194,10 +201,11 @@ export function codexAppServerTurnStateFromAgentRun(run = {}, normalizeRunState
194
201
  }
195
202
 
196
203
  export function codexAppServerTurnState(session = {}, normalizeRunState = normalizeCodexAppServerRunState) {
197
- const run = codexAppServerAgentRun(session);
198
- if (run) {
199
- return codexAppServerTurnStateFromAgentRun(run, normalizeRunState);
200
- }
204
+ return codexAppServerTurnStateFromAgentRun(codexAppServerAgentRun(session), normalizeRunState);
205
+ }
206
+
207
+ /** The turn of a session or binding that has no saved Codex run. */
208
+ function codexAppServerIdleTurnState() {
201
209
  return {
202
210
  active: false,
203
211
  completedAt: "",
@@ -4,6 +4,7 @@ import { isCompletedEnvelopeTurn } from "./transcript.js";
4
4
  import { ignorePromptRejection, ignorePromptSending } from "./nativeCommandCallbacks.js";
5
5
 
6
6
  // A final answer keeps its identity when an application edits its visible text.
7
+ // A turn's later replies repeat it here; distinctMessages tells them apart.
7
8
  export function conversationMessageIdentity(turnId, message) {
8
9
  return `${turnId}/${message.role}/${["user", "assistant", "system"].includes(message.role)
9
10
  ? "" : message.messageId || message.at}`;
@@ -16,6 +17,24 @@ export function conversationMessageVersion(message, { includeData = false } = {}
16
17
  })).digest("hex");
17
18
  }
18
19
 
20
+ /**
21
+ * Native continuations add later replies to a turn, and those repeat its identity. The first keeps
22
+ * it, because saved cursors and an edited final answer use it, and each later one adds its own id.
23
+ * The original version saved for a repeated identity names only the last reply written, so it is dropped.
24
+ * @param {object[]} messages
25
+ * @returns {object[]}
26
+ */
27
+ function distinctMessages(messages) {
28
+ const repeats = Map.groupBy(messages, message => message.id);
29
+ return messages.map(message => {
30
+ const repeated = repeats.get(message.id);
31
+ if (repeated.length === 1) return message;
32
+ const { originalVersion: _lastWritten, ...distinct } = message;
33
+ const position = repeated.indexOf(message);
34
+ return position ? { ...distinct, id: `${message.id}${message.messageId || position + 1}` } : distinct;
35
+ });
36
+ }
37
+
19
38
  // A changeover whose owner keeps no delivery log.
20
39
  function logNothing() {}
21
40
 
@@ -70,7 +89,7 @@ export function createConversationChangeover({ state, transcript, agent, identit
70
89
  lastEngine: engineId,
71
90
  engines: {
72
91
  [engineId]: {
73
- seen: Object.fromEntries(messages.map((message) => [message.id, message.originalVersion || message.version]))
92
+ seen: Object.fromEntries(distinctMessages(messages).map((message) => [message.id, message.originalVersion || message.version]))
74
93
  }
75
94
  }
76
95
  };
@@ -81,7 +100,7 @@ export function createConversationChangeover({ state, transcript, agent, identit
81
100
  const seen = value.engines[engineId].seen;
82
101
  // New replies from the last engine are already in that native conversation.
83
102
  // Preserve old fingerprints: an edited bubble still needs to be delivered.
84
- for (const message of messages) {
103
+ for (const message of distinctMessages(messages)) {
85
104
  if (message.engineId === engineId && !Object.hasOwn(seen, message.id)) {
86
105
  seen[message.id] = message.originalVersion || message.version;
87
106
  }
@@ -146,16 +165,17 @@ export function createConversationChangeover({ state, transcript, agent, identit
146
165
  code: unconfirmedCode, messageId, threadId, error: unconfirmedMessage };
147
166
  }
148
167
 
149
- async function send({ engineId, messages, input, turnMetadata, completedEnvelope = false, excludedMessageIds = [], inspectOnly = false }) {
168
+ async function send({ engineId, messages: written, input, turnMetadata, completedEnvelope = false, excludedMessageIds = [], inspectOnly = false }) {
150
169
  if (typeof completedEnvelope !== "boolean" || !Array.isArray(excludedMessageIds) ||
151
170
  excludedMessageIds.some(id => typeof id !== "string" || !id.trim() || id.length > 128) ||
152
171
  new Set(excludedMessageIds).size !== excludedMessageIds.length || excludedMessageIds.length && !completedEnvelope) {
153
172
  throw new TypeError("Native history exclusions require a tracked completed envelope and exact message IDs.");
154
173
  }
155
174
  const excluded = new Set(excludedMessageIds);
156
- const value = await readState({ engineId, messages });
175
+ const value = await readState({ engineId, messages: written });
157
176
  const binding = value.engines[engineId] ||= { seen: {} };
158
- rememberReplies(value, engineId, messages);
177
+ rememberReplies(value, engineId, written);
178
+ const messages = distinctMessages(written);
159
179
  const persist = () => state.write(value);
160
180
  async function markDelivered(seen) {
161
181
  const replacement = value.replacement;
@@ -383,7 +403,7 @@ function writtenMessages(history, native = false) {
383
403
  export const conversationNativeMessages = history => writtenMessages(history, true);
384
404
 
385
405
  export function conversationHistoryVersions(history) {
386
- return Object.fromEntries(writtenMessages(history).map(message => [message.id, message.version]));
406
+ return Object.fromEntries(distinctMessages(writtenMessages(history)).map(message => [message.id, message.version]));
387
407
  }
388
408
 
389
409
  export function conversationRequestText({ text, origin, data }) {
@@ -1,3 +1,4 @@
1
+ import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
1
2
  import { createConversationSystemPrompt } from "../systemPrompt.js";
2
3
 
3
4
  /**
@@ -44,7 +45,12 @@ export function createClaudeConversationAdapter({ getProcess, isActive, startPro
44
45
  const canUpdate = getProcess() && installedPrompt === systemPrompt && installedMode === instructionMode &&
45
46
  liveUpdateIdentity !== undefined && updateIdentity === liveUpdateIdentity;
46
47
  if (getProcess() && isActive() && !canUpdate && !instructions.isCurrent({ systemPrompt, contextIdentity })) {
47
- throw new Error("Stop the current Claude turn before changing its settings.");
48
+ // Steering never gets here: it keeps the running installation. This is an
49
+ // explicit settings or command preparation while work is running.
50
+ throw new AppError(409, "Stop the current Claude turn before changing its settings.", {
51
+ code: "conversation_claude_settings_change_while_working",
52
+ messageCode: "assistant.claude.settingsChangeWhileWorking"
53
+ });
48
54
  }
49
55
  return instructions.ensure({
50
56
  systemPrompt,
@@ -949,6 +949,36 @@ test("Claude status retains active instructions and applies changed guidance onc
949
949
  assert.equal(f.processes.length, 2, "Idle readiness installs the changed instructions");
950
950
  });
951
951
 
952
+ // A repair chat edits the project's Genesis files while it works, so its
953
+ // composed instructions change mid-turn. A message typed then is steering.
954
+ for (const main of [true, false]) test(`Claude steering keeps the running instructions after guidance changed mid-turn and idle admission applies them, main=${main}`, async t => {
955
+ const f = await publishedClaudeFixture(t);
956
+ const entry = main ? f.entry : await f.owner.open(f.context, {
957
+ conversationId: "87654321-4321-4321-8321-cba987654321", mainId: f.conversationId, create: true
958
+ });
959
+ assert.equal(entry.main, main);
960
+ await f.owner.startTurn(entry, { message: "Repair the setup", messageId: "repair", persistent: true });
961
+ const running = entry.process;
962
+ f.behavior.systemPrompt = "Guidance the repair changed during its own turn.";
963
+ const steered = await f.owner.startTurn(entry, { message: "The saved version already has this", messageId: "midturn", steer: true });
964
+ assert.deepEqual([steered.ok, entry.nativeTurn.read().active], [true, true]);
965
+ assert.equal(entry.process, running, "the steer keeps the running process and its instructions");
966
+ assert.equal(f.processes.length, 1);
967
+ assert.equal(f.behavior.nativeInputs.at(-1), "The saved version already has this", "the steer reached the running Claude");
968
+ await entry.process.options.onEvent({ session_id: entry.id, type: "result", subtype: "success", result: "Done" });
969
+ await f.owner.startTurn(entry, { message: "Next request", messageId: "next" });
970
+ assert.equal(f.processes.length, 2, "the next idle admission installs the changed guidance");
971
+ });
972
+
973
+ test("an explicit Claude settings change while work runs is refused with its own code", async t => {
974
+ const f = await publishedClaudeFixture(t);
975
+ await f.send();
976
+ await assert.rejects(f.entry.nativeTurn.prepare({ systemPrompt: "Changed guidance", settings: {}, model: "sonnet" }), error =>
977
+ error.code === "conversation_claude_settings_change_while_working" && error.statusCode === 409 &&
978
+ error.messageCode === "assistant.claude.settingsChangeWhileWorking");
979
+ assert.equal(f.processes.length, 1);
980
+ });
981
+
952
982
  test("Claude active status keeps a running turn across an account change; idle admission applies it", async t => {
953
983
  const f = await publishedClaudeFixture(t);
954
984
  await f.send();
@@ -1740,6 +1740,28 @@ test("Codex cancellation drains owned work, and a deadline saves a failed turn",
1740
1740
  assert.equal(trace.filter(row => row.args).length, 1);
1741
1741
  });
1742
1742
 
1743
+ // An upgraded binding keeps its native thread but has no saved Codex run. With
1744
+ // its Codex account gone, reading, Stop and selection still start nothing.
1745
+ test("a Codex binding with no saved run reads ready, and Stop and selection start no Codex work", async t => {
1746
+ const f = await fixture(t, apiOptions());
1747
+ await f.first.close();
1748
+ await f.storage.write("conversation", async transaction => {
1749
+ const metadata = await transaction.readMetadata();
1750
+ assert.equal(Object.hasOwn(metadata.runtime.binding, "codexAppServerRun"), false);
1751
+ metadata.runtime.binding = { ...metadata.runtime.binding, threadId: "imported-thread", processInstanceId: "" };
1752
+ await transaction.writeMetadata(metadata);
1753
+ });
1754
+ await rm(f.account);
1755
+ const conversation = await f.runtime().open({ id: "conversation" });
1756
+ const state = await conversation.read();
1757
+ assert.equal(state.status, "ready");
1758
+ assert.equal(state.phase, "");
1759
+ assert.deepEqual(await conversation.cancel(), { stopped: false });
1760
+ await select(conversation, "api", "imported-to-api");
1761
+ assert.equal((await conversation.read()).engine, "api");
1762
+ await assert.rejects(f.trace(), { code: "ENOENT" }, "No Codex process was started");
1763
+ });
1764
+
1743
1765
  test("lost Codex acknowledgement stays uncertain until native history proves the exact message", async t => {
1744
1766
  const f = await fixture(t);
1745
1767
  const lost = { messageId: "lost-id", text: "lost" };
@@ -19,6 +19,7 @@ import {
19
19
  codexAppServerAgentRunPatch,
20
20
  codexAppServerPendingUserMessageOwnership,
21
21
  codexAppServerTurnState,
22
+ codexAppServerTurnStateFromAgentRun,
22
23
  CODEX_APP_SERVER_RUN_STATE
23
24
  } from "../src/server/conversation/codexTurn.js";
24
25
 
@@ -563,6 +564,18 @@ test("original pending ownership and terminal outer identity are preserved witho
563
564
  error => error === hostError);
564
565
  });
565
566
 
567
+ test("no saved Codex run is no turn, while a saved run without a state is starting", () => {
568
+ for (const run of [undefined, null]) {
569
+ const turn = codexAppServerTurnStateFromAgentRun(run);
570
+ assert.equal(turn.active, false);
571
+ assert.equal(turn.state, "idle");
572
+ assert.deepEqual(turn, codexAppServerTurnState({ agentRuns: [] }));
573
+ }
574
+ const starting = codexAppServerTurnStateFromAgentRun({});
575
+ assert.equal(starting.active, true);
576
+ assert.equal(starting.state, "starting");
577
+ });
578
+
566
579
  test("native notification queues merge only waiting stream deltas and retain receipt order", async () => {
567
580
  const owner = createCodexAppServerNotificationQueue();
568
581
  const context = { sessionId: "session-1", sessionKey: "session-1", threadId: "thread-1", turnId: "turn-1" };
@@ -3672,6 +3685,160 @@ test("native history budget resolves only for new catch-up and preserves the ret
3672
3685
  assert.equal(sent[2].message, "Just these words");
3673
3686
  });
3674
3687
 
3688
+ // One saved turn that native continuations can add replies to, delivered through a changeover.
3689
+ async function continuedConversation() {
3690
+ const { createConversationChangeover, conversationNativeMessages, conversationHistoryVersions } = await import("../src/server/conversation/continuity.js");
3691
+ const storage = createMemoryConversationStorage();
3692
+ const transcript = createConversationTranscript({ storage });
3693
+ const { turnId } = await transcript.writeConversationUserMessage("chat", { messageId: "request", text: "Write a report",
3694
+ at: "2026-10-01T01:00:00.000Z", turnMetadata: { runtime: { engine: "claude" } } });
3695
+ let saved = { lastEngine: "claude", engines: { claude: { seen: {} } } };
3696
+ let sends = 0;
3697
+ let prompt = "";
3698
+ const changeover = createConversationChangeover({
3699
+ state: { async read() { return structuredClone(saved); }, async write(next) { saved = structuredClone(next); } },
3700
+ transcript: { async hasMessage() { return false; } },
3701
+ agent: { async sendMessage(input) { prompt = input.message; return { ok: true, delivered: true }; } }
3702
+ });
3703
+ const turns = () => transcript.readConversationLog("chat");
3704
+ let written = 0;
3705
+ return {
3706
+ transcript, turnId, turns,
3707
+ reply: (messageId, text) => transcript.writeConversationAssistantMessage("chat", { messageId, text, turnId,
3708
+ at: `2026-10-01T01:00:0${++written}.000Z` }),
3709
+ edit: (messageId, text) => storage.write("chat", async tx => {
3710
+ const message = (await tx.readTurn(turnId)).messages.find(row => row.messageId === messageId);
3711
+ await tx.appendMessage(turnId, { ...message, text });
3712
+ }),
3713
+ history: async () => conversationNativeMessages(await turns()),
3714
+ versions: async () => conversationHistoryVersions(await turns()),
3715
+ save(value) { saved = value; },
3716
+ seen: (engineId = "claude") => saved.engines[engineId].seen,
3717
+ async send(engineId = "claude", messages) {
3718
+ await changeover.send({ engineId, messages: messages || conversationNativeMessages(await turns()),
3719
+ input: { messageId: `follow-up-${++sends}`, message: "Next" } });
3720
+ return prompt;
3721
+ }
3722
+ };
3723
+ }
3724
+
3725
+ // What a changeover preamble carried: each message's id, text and correction mark, and the removals.
3726
+ function carriedHistory(prompt) {
3727
+ const { messages, removedMessageIds } = JSON.parse(prompt.split("\n").find(line => line.startsWith('{"messages"')));
3728
+ return { messages: messages.map(message => [message.messageId, message.text, message.corrected]), removedMessageIds };
3729
+ }
3730
+
3731
+ test("replies that share a turn are each remembered, so later messages carry no changeover preamble", async () => {
3732
+ const f = await continuedConversation();
3733
+ await f.reply("interim", "The query is still running.");
3734
+ await f.reply("report", "The report.");
3735
+ assert.equal(await f.send(), "Next");
3736
+ assert.equal(await f.send(), "Next");
3737
+ // A native continuation after that delivery is already in the engine's own conversation.
3738
+ await f.reply("late", "The stale helper stopped.");
3739
+ assert.equal(await f.send(), "Next");
3740
+ assert.deepEqual(f.seen(), await f.versions());
3741
+ assert.deepEqual(Object.keys(f.seen()), ["000001/user/", "000001/assistant/", "000001/assistant/report", "000001/assistant/late"]);
3742
+ });
3743
+
3744
+ test("replies without message ids keep separate identities in one turn", async () => {
3745
+ const f = await continuedConversation();
3746
+ await f.transcript.writeConversationAssistantMessage("chat", { text: "First", turnId: f.turnId, at: "2026-10-01T01:00:01.000Z" });
3747
+ await f.transcript.writeConversationAssistantMessage("chat", { text: "Second", turnId: f.turnId, at: "2026-10-01T01:00:02.000Z" });
3748
+ assert.equal(await f.send(), "Next");
3749
+ assert.equal(await f.send(), "Next");
3750
+ assert.deepEqual(Object.keys(f.seen()), ["000001/user/", "000001/assistant/", "000001/assistant/2"]);
3751
+ });
3752
+
3753
+ test("a corrected reply in a shared turn is delivered alone, and merged replies are reported removed", async () => {
3754
+ const f = await continuedConversation();
3755
+ await f.reply("interim", "The query is still running.");
3756
+ await f.reply("report", "The report.");
3757
+ assert.equal(await f.send(), "Next");
3758
+ await f.edit("report", "The corrected report.");
3759
+ assert.deepEqual(carriedHistory(await f.send()),
3760
+ { messages: [["report", "The corrected report.", true]], removedMessageIds: [] });
3761
+ assert.equal(await f.send(), "Next");
3762
+ await f.edit("interim", "The corrected status.");
3763
+ assert.deepEqual(carriedHistory(await f.send()),
3764
+ { messages: [["interim", "The corrected status.", true]], removedMessageIds: [] });
3765
+ assert.equal(await f.send(), "Next");
3766
+ // An application's edit replaces every reply in the turn and keeps the first one's identity.
3767
+ await f.transcript.upsertConversationAssistantMessage("chat", { turnId: f.turnId, text: "One merged answer." });
3768
+ assert.deepEqual(carriedHistory(await f.send()),
3769
+ { messages: [["interim", "One merged answer.", true]], removedMessageIds: ["000001/assistant/report"] });
3770
+ assert.equal(await f.send(), "Next");
3771
+ });
3772
+
3773
+ test("a cursor saved while replies shared one identity delivers one correction, then none", async () => {
3774
+ const f = await continuedConversation();
3775
+ await f.reply("interim", "The query is still running.");
3776
+ await f.reply("report", "The report.");
3777
+ const [user, first, last] = await f.history();
3778
+ f.save({ lastEngine: "claude", engines: { claude: { seen: { [user.id]: user.version, [first.id]: last.version } } } });
3779
+ assert.deepEqual(carriedHistory(await f.send()),
3780
+ { messages: [["interim", "The query is still running.", true]], removedMessageIds: [] });
3781
+ assert.deepEqual(f.seen(), await f.versions());
3782
+ assert.equal(await f.send(), "Next");
3783
+ });
3784
+
3785
+ test("a cursor saved with a turn's first reply is already current", async () => {
3786
+ const f = await continuedConversation();
3787
+ await f.reply("interim", "The query is still running.");
3788
+ await f.reply("report", "The report.");
3789
+ const [user, first] = await f.history();
3790
+ f.save({ lastEngine: "claude", engines: { claude: { seen: { [user.id]: user.version, [first.id]: first.version } } } });
3791
+ assert.equal(await f.send(), "Next");
3792
+ assert.deepEqual(f.seen(), await f.versions());
3793
+ assert.equal(await f.send(), "Next");
3794
+ });
3795
+
3796
+ test("a cursor saved while replies shared one identity delivers a returning engine's turn once", async () => {
3797
+ const f = await continuedConversation();
3798
+ await f.reply("interim", "The query is still running.");
3799
+ await f.reply("report", "The report.");
3800
+ const [user, first, last] = await f.history();
3801
+ await f.reply("late", "The stale helper stopped.");
3802
+ f.save({ lastEngine: "claude", engines: { codex: { seen: { [user.id]: user.version, [first.id]: last.version } } } });
3803
+ assert.deepEqual(carriedHistory(await f.send("codex")), { messages: [
3804
+ ["interim", "The query is still running.", true], ["report", "The report.", undefined],
3805
+ ["late", "The stale helper stopped.", undefined]], removedMessageIds: [] });
3806
+ assert.deepEqual(f.seen("codex"), await f.versions());
3807
+ assert.equal(await f.send("codex"), "Next");
3808
+ });
3809
+
3810
+ test("a cursor saved while replies shared one identity keeps real corrections", async () => {
3811
+ const shared = await continuedConversation();
3812
+ await shared.reply("interim", "The query is still running.");
3813
+ await shared.reply("report", "The report.");
3814
+ const [user, first] = await shared.history();
3815
+ shared.save({ lastEngine: "claude", engines: { claude: { seen: { [user.id]: user.version, [first.id]: "before-the-edit" } } } });
3816
+ assert.deepEqual(carriedHistory(await shared.send()),
3817
+ { messages: [["interim", "The query is still running.", true]], removedMessageIds: [] });
3818
+ assert.equal(await shared.send(), "Next");
3819
+
3820
+ const merged = await continuedConversation();
3821
+ await merged.reply("interim", "The query is still running.");
3822
+ await merged.reply("report", "The report.");
3823
+ const [mergedUser, , reportBeforeEdit] = await merged.history();
3824
+ merged.save({ lastEngine: "claude", engines: { claude: { seen: { [mergedUser.id]: mergedUser.version,
3825
+ [reportBeforeEdit.id]: reportBeforeEdit.version } } } });
3826
+ await merged.transcript.upsertConversationAssistantMessage("chat", { turnId: merged.turnId, text: "One merged answer." });
3827
+ assert.deepEqual(carriedHistory(await merged.send()),
3828
+ { messages: [["interim", "One merged answer.", true]], removedMessageIds: [] });
3829
+ assert.equal(await merged.send(), "Next");
3830
+ });
3831
+
3832
+ test("a host's history may repeat a turn's identity and its last written original version", async () => {
3833
+ const f = await continuedConversation();
3834
+ const reply = { id: "000001/assistant/", role: "assistant", engineId: "claude", originalVersion: "report-version" };
3835
+ const messages = [{ ...reply, messageId: "interim", text: "Running.", version: "interim-version" },
3836
+ { ...reply, messageId: "report", text: "The report.", version: "report-version" }];
3837
+ assert.equal(await f.send("claude", messages), "Next");
3838
+ assert.deepEqual(f.seen(), { "000001/assistant/": "interim-version", "000001/assistant/report": "report-version" });
3839
+ assert.equal(await f.send("claude", messages), "Next");
3840
+ });
3841
+
3675
3842
  test("native history budget rejects unavailable dynamic limits before pending delivery", async () => {
3676
3843
  const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
3677
3844
  for (const budget of [0, -1, NaN, Infinity, "4096", undefined]) {