@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.
- package/examples/conversation/package.json +5 -5
- package/locales/en.json +1 -0
- package/package.json +5 -5
- package/src/server/conversation/claudePromptDelivery.js +5 -3
- package/src/server/conversation/codexTurnState.js +13 -5
- package/src/server/conversation/continuity.js +26 -6
- package/src/server/conversation/providers/claude.js +7 -1
- package/test/claudeTurn.test.js +30 -0
- package/test/codexConversationSession.test.js +22 -0
- package/test/codexTurn.test.js +167 -0
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
"build": "vite build"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@jskit-ai/assistant-core": "0.2.
|
|
12
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
13
|
-
"@jskit-ai/kernel": "0.2.
|
|
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.
|
|
18
|
-
"@jskit-ai/connectors-catalog": "0.2.
|
|
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.
|
|
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.
|
|
55
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
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.
|
|
65
|
-
"@jskit-ai/kernel": "0.2.
|
|
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.
|
|
60
|
-
//
|
|
61
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
package/test/claudeTurn.test.js
CHANGED
|
@@ -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" };
|
package/test/codexTurn.test.js
CHANGED
|
@@ -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]) {
|