@jskit-ai/assistant-core 0.1.200 → 0.1.202
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/README.md +8 -1
- package/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/App.vue +4 -1
- package/package.json +99 -36
- package/src/client/conversation/AssistantConversationElement.vue +27 -4
- package/src/client/conversation/AssistantConversationStatus.vue +24 -0
- package/src/client/conversation/AssistantGoalControl.vue +8 -5
- package/src/client/conversation/AssistantTranscript.vue +10 -3
- package/src/client/conversation/index.js +2 -0
- package/src/client/conversation/messageDelivery.js +115 -32
- package/src/client/conversation/retainedConversation.js +32 -0
- package/src/client/conversation/useAssistantQuestions.js +76 -0
- package/src/client/lib/assistantApi.js +72 -2
- package/src/server/conversation/agentRun.js +222 -0
- package/src/server/conversation/attachments.js +93 -0
- package/src/server/conversation/claudeClient.js +221 -0
- package/src/server/conversation/claudeHistory.js +256 -0
- package/src/server/conversation/claudeProcess.js +327 -0
- package/src/server/conversation/claudeTools.js +42 -0
- package/src/server/conversation/claudeTurn.js +1082 -0
- package/src/server/conversation/codexConfiguration.js +1183 -0
- package/src/server/conversation/codexConversations.js +1082 -0
- package/src/server/conversation/codexDetachedTurn.js +332 -0
- package/src/server/conversation/codexEvents.js +194 -1
- package/src/server/conversation/codexGoalCommands.js +124 -0
- package/src/server/conversation/codexHelperExecution.js +306 -0
- package/src/server/conversation/codexHelperThreadLedger.js +804 -0
- package/src/server/conversation/codexHelperThreadRecovery.js +395 -0
- package/src/server/conversation/codexHelperThreads.js +638 -0
- package/src/server/conversation/codexHistoryAdapter.js +369 -0
- package/src/server/conversation/codexMessageCommands.js +663 -0
- package/src/server/conversation/codexMessageDelivery.js +211 -0
- package/src/server/conversation/codexNativeHistoryExport.js +95 -0
- package/src/server/conversation/codexNotificationObserver.js +482 -0
- package/src/server/conversation/codexNotificationQueue.js +103 -0
- package/src/server/conversation/codexObservationControl.js +174 -0
- package/src/server/conversation/codexOutput.js +931 -0
- package/src/server/conversation/codexProcess.js +1559 -0
- package/src/server/conversation/codexProcessLeader.js +14 -0
- package/src/server/conversation/codexProvider.js +2710 -0
- package/src/server/conversation/codexProviderOwner.js +1058 -0
- package/src/server/conversation/codexProviderSelection.js +218 -0
- package/src/server/conversation/codexReasoning.js +314 -0
- package/src/server/conversation/codexRenewalCommands.js +290 -0
- package/src/server/conversation/codexResultSettlement.js +409 -0
- package/src/server/conversation/codexRunJournal.js +658 -0
- package/src/server/conversation/codexRuntimeLifecycle.js +288 -0
- package/src/server/conversation/codexSessionCleanup.js +96 -0
- package/src/server/conversation/codexSessionReconciliation.js +284 -0
- package/src/server/conversation/codexThreadReadiness.js +397 -0
- package/src/server/conversation/codexThreadStatus.js +354 -0
- package/src/server/conversation/codexTurn.js +701 -255
- package/src/server/conversation/codexTurnControl.js +317 -0
- package/src/server/conversation/codexTurnRecovery.js +990 -0
- package/src/server/conversation/codexTurnState.js +549 -0
- package/src/server/conversation/commandHook.js +18 -0
- package/src/server/conversation/commandWrapper.js +37 -0
- package/src/server/conversation/configuration.js +43 -0
- package/src/server/conversation/continuity.js +289 -0
- package/src/server/conversation/fileStorage.js +55 -0
- package/src/server/conversation/hookBridge.js +19 -0
- package/src/server/conversation/index.js +13 -0
- package/src/server/conversation/localExecution.js +665 -0
- package/src/server/conversation/memoryStorage.js +7 -72
- package/src/server/conversation/nativeHistoryExport.js +90 -0
- package/src/server/conversation/nativeStorage.js +23 -0
- package/src/server/conversation/openCodeClient.js +209 -19
- package/src/server/conversation/openCodePlugin.js +84 -0
- package/src/server/conversation/openCodeProcess.js +476 -0
- package/src/server/conversation/openCodeRuntime.js +1578 -0
- package/src/server/conversation/openCodeTools.js +60 -0
- package/src/server/conversation/openCodeTurn.js +557 -0
- package/src/server/conversation/provider.js +30 -0
- package/src/server/conversation/providers/api.js +136 -0
- package/src/server/conversation/providers/claude.js +63 -0
- package/src/server/conversation/providers/claudeDriver.js +425 -0
- package/src/server/conversation/providers/codex.js +248 -0
- package/src/server/conversation/providers/codexDriver.js +1078 -0
- package/src/server/conversation/providers/opencode.js +49 -0
- package/src/server/conversation/providers/opencodeDriver.js +650 -0
- package/src/server/conversation/providers/scoped.js +312 -0
- package/src/server/conversation/runtime.js +2220 -0
- package/src/server/conversation/runtimeStateUpgrade.js +125 -0
- package/src/server/conversation/storage.js +184 -0
- package/src/server/conversation/streams.js +7 -2
- package/src/server/conversation/structuredOutput.js +240 -0
- package/src/server/conversation/systemPrompt.js +54 -0
- package/src/server/conversation/tools.js +84 -0
- package/src/server/conversation/transcript.js +5 -4
- package/src/server/hosts/claudeHistory.js +5 -0
- package/src/server/hosts/claudeProcess.js +11 -0
- package/src/server/hosts/claudeTurn.js +4 -0
- package/src/server/hosts/codexConfiguration.js +20 -0
- package/src/server/hosts/codexEvents.js +13 -0
- package/src/server/hosts/codexProcess.js +12 -0
- package/src/server/hosts/codexProvider.js +23 -0
- package/src/server/hosts/codexTurn.js +20 -0
- package/src/server/hosts/nativeHistory.js +3 -0
- package/src/server/hosts/openCodeClient.js +3 -0
- package/src/server/hosts/openCodeProcess.js +23 -0
- package/src/server/hosts/openCodeTurn.js +7 -0
- package/src/server/index.js +2 -0
- package/src/server/lib/aiConnectionClient.js +13 -6
- package/src/server/lib/assistantCompletion.js +190 -0
- package/src/server/lib/assistantToolLoop.js +314 -0
- package/src/server/lib/serviceToolCatalog.js +25 -10
- package/src/shared/assistantResponseText.js +29 -0
- package/src/shared/conversation/attachments.js +19 -0
- package/src/shared/conversation/index.js +3 -0
- package/src/shared/conversation/pagination.js +78 -0
- package/src/shared/conversation/patches.js +150 -0
- package/src/shared/conversation/questions.js +48 -0
- package/src/shared/conversation/streaming.js +7 -4
- package/src/shared/index.js +2 -0
- package/src/shared/nativeProviders.js +45 -0
- package/src/testing/conversationStorageContract.js +31 -1
- package/src/testing/nativeCodex.js +11 -0
- package/src/testing/nativeOpenCode.js +3 -0
- package/test/aiConnectionClient.test.js +2 -0
- package/test/apiConversationDriver.test.js +73 -0
- package/test/assistantApiSurfaceHeader.test.js +85 -0
- package/test/assistantBoundedToolLoop.test.js +285 -0
- package/test/assistantScroll.browser.test.js +16 -0
- package/test/claudeClient.test.js +189 -0
- package/test/claudeConversationSession.test.js +1173 -0
- package/test/claudeInstructionResume.test.js +82 -0
- package/test/claudeProcess.test.js +204 -0
- package/test/claudeTurn.test.js +253 -0
- package/test/codexConversationSession.test.js +2122 -0
- package/test/codexGoal.test.js +37 -0
- package/test/codexHelperDeletion.test.js +163 -0
- package/test/codexHelperThreadLedger.test.js +494 -0
- package/test/codexHistoryAdapter.test.js +541 -0
- package/test/codexInteractiveArguments.test.js +30 -0
- package/test/codexIsolation.test.js +756 -0
- package/test/codexModelCatalog.test.js +362 -0
- package/test/codexNativeHistoryExport.test.js +81 -0
- package/test/codexThreadSelection.test.js +136 -0
- package/test/codexTurn.test.js +2158 -0
- package/test/conversation.test.js +110 -0
- package/test/conversationAgentRun.test.js +326 -0
- package/test/conversationApiFacilities.test.js +90 -0
- package/test/conversationAttachments.test.js +238 -0
- package/test/conversationCommandWrapper.test.js +48 -0
- package/test/conversationConfiguration.test.js +174 -0
- package/test/conversationNativeStorage.test.js +25 -0
- package/test/conversationReplacement.test.js +172 -0
- package/test/conversationRuntime.test.js +241 -0
- package/test/conversationRuntimeStateUpgrade.test.js +144 -0
- package/test/conversationSession.test.js +1080 -0
- package/test/conversationStreaming.test.js +68 -0
- package/test/conversationSystemPrompt.test.js +84 -0
- package/test/conversationTools.test.js +416 -0
- package/test/fileConversationStorage.test.js +73 -0
- package/test/fixtures/codexHelperThreadLedger.js +39 -0
- package/test/localConversationExecution.test.js +90 -0
- package/test/messageDelivery.test.js +234 -1
- package/test/nativeConversationRetirement.test.js +215 -0
- package/test/openCodeClient.test.js +515 -0
- package/test/openCodeProcess.test.js +177 -0
- package/test/openCodeRuntime.test.js +322 -0
- package/test/openCodeTools.test.js +287 -0
- package/test/opencodeConversationSession.test.js +789 -0
- package/test/serviceToolCatalog.test.js +24 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createConversationRuntime, createMemoryConversationStorage } from "../src/server/conversation/index.js";
|
|
4
|
+
import { createAiConnectionResolver } from "../../connectors-catalog/src/server/ai.js";
|
|
5
|
+
|
|
6
|
+
const configuration = { systemPrompt: "Answer the current question.", integrationId: "assistant" };
|
|
7
|
+
const connections = createAiConnectionResolver({
|
|
8
|
+
configuration: { schemaVersion: 1, registrations: {}, integrations: {
|
|
9
|
+
assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/big-pickle" } }
|
|
10
|
+
} }, authorize: () => ({ applicationId: "test", subjectId: "owner" })
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
async function fixture(t, options = {}) {
|
|
14
|
+
const storage = options.storage || createMemoryConversationStorage();
|
|
15
|
+
const requests = [];
|
|
16
|
+
const runtimes = [];
|
|
17
|
+
const state = { allowed: true, failCommit: false, commitFailures: 0 };
|
|
18
|
+
const failingStorage = { ...storage, write: (id, callback) => storage.write(id, async transaction => {
|
|
19
|
+
const previous = await transaction.readMetadata();
|
|
20
|
+
const result = await callback(transaction);
|
|
21
|
+
const next = await transaction.readMetadata();
|
|
22
|
+
if (state.failCommit && previous.runtime?.replacement && !next.runtime?.replacement) {
|
|
23
|
+
state.commitFailures++;
|
|
24
|
+
throw new Error("Storage offline during replacement commit");
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}) };
|
|
28
|
+
function runtime() {
|
|
29
|
+
const value = createConversationRuntime({ storage: failingStorage, connections,
|
|
30
|
+
authorize: () => state.allowed, limits: options.limits,
|
|
31
|
+
fetch: async (_url, init) => {
|
|
32
|
+
const body = JSON.parse(init.body); requests.push(body);
|
|
33
|
+
if (options.fetch) return options.fetch(body, init);
|
|
34
|
+
const text = `Reply ${requests.length}`;
|
|
35
|
+
const chunk = { choices: [{ index: 0, delta: { content: text }, finish_reason: "stop" }] };
|
|
36
|
+
return new Response(`data: ${JSON.stringify(chunk)}\n\n`, { headers: { "content-type": "text/event-stream" } });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
runtimes.push(value);
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
t.after(async () => { for (const value of runtimes) await value.close(); });
|
|
43
|
+
const first = runtime();
|
|
44
|
+
const conversation = await first.open({ id: "one", configuration });
|
|
45
|
+
async function say(text, messageId) { await conversation.send({ text, messageId }); return conversation.wait(); }
|
|
46
|
+
const replacement = async (patch = {}) => ({ operationId: "replace-one", expectedSegmentId: (await conversation.read()).segmentId,
|
|
47
|
+
reason: "renewal", briefing: "Retain the agreed colour: blue.", ...patch });
|
|
48
|
+
return { storage, conversation, first, runtime, requests, state, say, replacement };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("renewal preserves logical history and receipts while subsequent inference carries the saved briefing", async t => {
|
|
52
|
+
const f = await fixture(t);
|
|
53
|
+
await f.say("Keep my context", "first");
|
|
54
|
+
const operation = await f.replacement();
|
|
55
|
+
const result = await f.conversation.replace(operation);
|
|
56
|
+
assert.notEqual(result.segmentId, operation.expectedSegmentId);
|
|
57
|
+
assert.equal(f.requests.length, 1, "Replacement itself does not perform inference");
|
|
58
|
+
await f.say("Continue", "second");
|
|
59
|
+
const sent = f.requests[1].messages;
|
|
60
|
+
assert.equal(sent.at(-1).content, "Continue");
|
|
61
|
+
assert.match(sent[1].content, /Retain the agreed colour: blue/);
|
|
62
|
+
assert.match(sent[1].content, /Keep my context/);
|
|
63
|
+
assert.match(sent[1].content, /Reply 1/);
|
|
64
|
+
assert.equal(sent.filter(message => message.content === "Keep my context").length, 0);
|
|
65
|
+
const snapshot = await f.conversation.read();
|
|
66
|
+
assert.deepEqual(snapshot.conversationLog.map(turn => turn.user.text), ["Keep my context", "Continue"]);
|
|
67
|
+
assert.equal((await f.conversation.send({ text: "Keep my context", messageId: "first" })).duplicate, true);
|
|
68
|
+
const retry = await f.conversation.replace(operation);
|
|
69
|
+
assert.equal(retry.duplicate, true);
|
|
70
|
+
assert.equal(retry.segmentId, result.segmentId);
|
|
71
|
+
await assert.rejects(f.conversation.replace({ ...operation, briefing: "Different" }), { code: "conversation_replacement_conflict" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("completed historical branch markers retain history filtering and admission receipts", async t => {
|
|
75
|
+
const f = await fixture(t);
|
|
76
|
+
await f.say("Retained", "first");
|
|
77
|
+
const previous = await f.say("Discarded", "second");
|
|
78
|
+
const discarded = previous.conversationLog[1];
|
|
79
|
+
await f.storage.write("one", transaction => transaction.updateTurnMetadata(discarded.turnId, {
|
|
80
|
+
runtime: { ...discarded.metadata.runtime, supersededBy: "historical-successor" }
|
|
81
|
+
}));
|
|
82
|
+
assert.deepEqual((await f.conversation.read()).conversationLog.map(turn => turn.user.text), ["Retained"]);
|
|
83
|
+
await f.say("Continue", "third");
|
|
84
|
+
assert.match(JSON.stringify(f.requests[2].messages), /Retained/);
|
|
85
|
+
assert.doesNotMatch(JSON.stringify(f.requests[2].messages), /Discarded/);
|
|
86
|
+
assert.equal((await f.conversation.send({ text: "Discarded", messageId: "second" })).duplicate, true);
|
|
87
|
+
const recorded = await f.storage.read("one", async tx => Promise.all((await tx.listTurnIds()).map(id => tx.readTurn(id))));
|
|
88
|
+
assert.equal(recorded.length, 3);
|
|
89
|
+
assert.ok(recorded[1].metadata.runtime.supersededBy);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("unfinished historical Undo operations require offline inspection without changing saved state", async t => {
|
|
93
|
+
const f = await fixture(t);
|
|
94
|
+
await f.say("Before", "first");
|
|
95
|
+
const operation = await f.replacement();
|
|
96
|
+
const metadata = await f.storage.read("one", transaction => transaction.readMetadata());
|
|
97
|
+
for (const patch of [{ reason: "rewind" },
|
|
98
|
+
{ request: { ...operation, operation: "replace", reason: "rewind" } },
|
|
99
|
+
{ request: { ...operation, operation: "replace", throughTurnId: null } },
|
|
100
|
+
{ supersededTurns: ["000001"] }]) {
|
|
101
|
+
const saved = structuredClone(metadata);
|
|
102
|
+
saved.runtime.replacement = { request: { ...operation, operation: "replace" }, reason: "renewal", engine: "api",
|
|
103
|
+
configuration, segmentId: "historical-successor", seen: {}, ...patch };
|
|
104
|
+
await f.storage.write("one", transaction => transaction.writeMetadata(saved));
|
|
105
|
+
await assert.rejects(f.conversation.replace(operation), { code: "conversation_rewind_inspection_required" });
|
|
106
|
+
await assert.rejects(f.runtime().open({ id: "one" }), { code: "conversation_rewind_inspection_required" });
|
|
107
|
+
assert.deepEqual(await f.storage.read("one", transaction => transaction.readMetadata()), saved);
|
|
108
|
+
assert.equal((await f.conversation.read()).conversationLog.length, 1);
|
|
109
|
+
assert.equal(f.requests.length, 1);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("an interrupted replacement blocks sending and resumes exactly once after application restart", async t => {
|
|
114
|
+
const f = await fixture(t);
|
|
115
|
+
await f.say("Before", "first");
|
|
116
|
+
const operation = await f.replacement();
|
|
117
|
+
f.state.failCommit = true;
|
|
118
|
+
await assert.rejects(f.conversation.replace(operation), /Storage offline/);
|
|
119
|
+
assert.equal((await f.conversation.read()).status, "replacement-pending");
|
|
120
|
+
await assert.rejects(f.conversation.send({ text: "Too early", messageId: "early" }), { code: "conversation_replacement_pending" });
|
|
121
|
+
await assert.rejects(f.conversation.configure({ systemPrompt: "Too early" }), { code: "conversation_replacement_pending" });
|
|
122
|
+
await f.first.close();
|
|
123
|
+
f.state.failCommit = false;
|
|
124
|
+
const resumed = await f.runtime().open({ id: "one" });
|
|
125
|
+
const result = await resumed.replace(operation);
|
|
126
|
+
await resumed.send({ text: "After", messageId: "after" });
|
|
127
|
+
const saved = await f.storage.read("one", tx => tx.readMetadata());
|
|
128
|
+
assert.equal(saved.runtime.predecessors.length, 1);
|
|
129
|
+
assert.equal(saved.runtime.segmentId, result.segmentId);
|
|
130
|
+
assert.equal((await resumed.wait()).conversationLog.length, 2);
|
|
131
|
+
assert.equal(f.requests.length, 2);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("invalid, stale and forbidden replacement requests do not alter the conversation", async t => {
|
|
135
|
+
const f = await fixture(t);
|
|
136
|
+
const operation = await f.replacement();
|
|
137
|
+
await assert.rejects(f.conversation.replace({ ...operation, expectedSegmentId: "stale" }), { code: "conversation_replacement_conflict" });
|
|
138
|
+
await assert.rejects(f.conversation.replace({ ...operation, reason: "rewind" }), { code: "conversation_invalid_replacement" });
|
|
139
|
+
await assert.rejects(f.conversation.replace({ ...operation, reason: "rewind", throughTurnId: "missing" }), { code: "conversation_invalid_replacement" });
|
|
140
|
+
await assert.rejects(f.conversation.replace({ ...operation, reason: "rewind", throughTurnId: null }), { code: "conversation_invalid_replacement" });
|
|
141
|
+
await assert.rejects(f.conversation.replace({ ...operation, throughTurnId: null }), { code: "conversation_invalid_replacement" });
|
|
142
|
+
await assert.rejects(f.conversation.replace({ ...operation, engine: "missing" }), /does not yet support/);
|
|
143
|
+
f.state.allowed = false;
|
|
144
|
+
await assert.rejects(f.conversation.replace(operation), { code: "conversation_forbidden" });
|
|
145
|
+
f.state.allowed = true;
|
|
146
|
+
assert.equal((await f.conversation.read()).segmentId, operation.expectedSegmentId);
|
|
147
|
+
assert.equal((await f.conversation.read()).replacement, null);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("carry-over explicitly marks omitted history and rejects an oversized briefing before replacement", async t => {
|
|
151
|
+
const f = await fixture(t, { limits: { maxContinuityCharacters: 800 } });
|
|
152
|
+
await f.say("X".repeat(2500), "first");
|
|
153
|
+
const operation = await f.replacement({ briefing: "" });
|
|
154
|
+
await assert.rejects(f.conversation.replace({ ...operation, briefing: "X".repeat(900) }), /briefing exceeds/);
|
|
155
|
+
assert.equal((await f.conversation.read()).replacement, null);
|
|
156
|
+
await f.conversation.replace(operation);
|
|
157
|
+
await f.say("Continue", "second");
|
|
158
|
+
assert.match(f.requests[1].messages[1].content, /"omittedMessages":1/);
|
|
159
|
+
assert.ok(f.requests[1].messages[1].content.length <= 800);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("host scope belongs to a conversation and cannot be changed through another open handle", async t => {
|
|
163
|
+
const f = await fixture(t);
|
|
164
|
+
const host = { workdir: "/application/one", env: { HOME: "/accounts/one" } };
|
|
165
|
+
const one = await f.first.open({ id: "scoped-one", host, configuration });
|
|
166
|
+
const two = await f.first.open({ id: "scoped-two", host: { workdir: "/application/two" }, configuration });
|
|
167
|
+
await assert.rejects(f.first.open({ id: "scoped-one", host: { workdir: "/application/other" } }), { code: "conversation_host_mismatch" });
|
|
168
|
+
await one.send({ messageId: "one", text: "One" });
|
|
169
|
+
await two.send({ messageId: "two", text: "Two" });
|
|
170
|
+
assert.equal((await one.wait()).conversationLog.length, 1);
|
|
171
|
+
assert.equal((await two.wait()).conversationLog.length, 1);
|
|
172
|
+
});
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createConversationHookBridge } from "../src/server/conversation/index.js";
|
|
4
|
+
import { createClaudeConversationTurn } from "../src/server/conversation/claudeTurn.js";
|
|
5
|
+
import { createCodexConversationAdapter } from "../src/server/conversation/providers/codex.js";
|
|
6
|
+
import { createOpenCodeConversationAdapter } from "../src/server/conversation/providers/opencode.js";
|
|
7
|
+
|
|
8
|
+
function claudeHost() {
|
|
9
|
+
let process = null;
|
|
10
|
+
const launches = [], requests = [];
|
|
11
|
+
const state = { active: false, fail: false, stopFails: false };
|
|
12
|
+
const runtime = createClaudeConversationTurn({ conversationId: "retained-history", process: {
|
|
13
|
+
getProcess: () => process, isActive: () => state.active,
|
|
14
|
+
startProcess: async (input) => {
|
|
15
|
+
launches.push(input);
|
|
16
|
+
process = { client: { request: async (request) => {
|
|
17
|
+
requests.push(request);
|
|
18
|
+
if (state.fail) throw new Error("native failure");
|
|
19
|
+
} } };
|
|
20
|
+
return process;
|
|
21
|
+
},
|
|
22
|
+
stopProcess: async () => { if (state.stopFails) throw new Error("cleanup unverified"); process = null; }
|
|
23
|
+
} });
|
|
24
|
+
return { runtime, state, launches, requests, loseProcess: () => { process = null; } };
|
|
25
|
+
}
|
|
26
|
+
const claudeInput = { systemPrompt: "Use the calendar tools.", contextIdentity: "calendar/model-one",
|
|
27
|
+
model: "one", settings: { effortLevel: "high" }, liveUpdateIdentity: "calendar-schema" };
|
|
28
|
+
|
|
29
|
+
test("a standalone Claude consumer reuses, updates and replaces processes without changing native history", async () => {
|
|
30
|
+
const f = claudeHost();
|
|
31
|
+
const input = { ...claudeInput, nativeId: "retained-history" };
|
|
32
|
+
await Promise.all([f.runtime.prepare(input), f.runtime.prepare(input)]);
|
|
33
|
+
assert.equal(f.launches.length, 1);
|
|
34
|
+
assert.deepEqual(f.launches[0].instructionArguments, ["--append-system-prompt", input.systemPrompt, "--system-prompt-snapshot", "off"]);
|
|
35
|
+
await f.runtime.prepare({ ...input, contextIdentity: "calendar/model-two", model: "two" });
|
|
36
|
+
assert.equal(f.launches.length, 1);
|
|
37
|
+
assert.deepEqual(f.requests.at(-1), { subtype: "set_model", model: "two" });
|
|
38
|
+
const changed = { ...input, systemPrompt: "Use the updated calendar tools.", instructionMode: "replace" };
|
|
39
|
+
await f.runtime.prepare(changed);
|
|
40
|
+
assert.equal(f.launches.length, 2);
|
|
41
|
+
assert.equal(f.launches[1].nativeId, "retained-history");
|
|
42
|
+
assert.deepEqual(f.launches[1].instructionArguments, ["--system-prompt", changed.systemPrompt, "--system-prompt-snapshot", "off"]);
|
|
43
|
+
f.loseProcess();
|
|
44
|
+
await f.runtime.prepare(changed);
|
|
45
|
+
assert.equal(f.launches.length, 3);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("Claude rejects active prompt changes without invalidating ordinary steering", async () => {
|
|
49
|
+
const f = claudeHost();
|
|
50
|
+
await f.runtime.prepare(claudeInput);
|
|
51
|
+
f.state.active = true;
|
|
52
|
+
await assert.rejects(f.runtime.prepare({ ...claudeInput, systemPrompt: "Changed" }), /Stop the current/);
|
|
53
|
+
await f.runtime.prepare(claudeInput);
|
|
54
|
+
assert.equal(f.launches.length, 1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("Claude restarts when only the native instruction mode changes", async () => {
|
|
58
|
+
const f = claudeHost();
|
|
59
|
+
await f.runtime.prepare(claudeInput);
|
|
60
|
+
await f.runtime.prepare({ ...claudeInput, instructionMode: "replace" });
|
|
61
|
+
assert.equal(f.launches.length, 2);
|
|
62
|
+
assert.deepEqual(f.launches[1].instructionArguments, ["--system-prompt", claudeInput.systemPrompt, "--system-prompt-snapshot", "off"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("Claude does not acknowledge failed native settings or launch past failed cleanup", async () => {
|
|
66
|
+
const f = claudeHost();
|
|
67
|
+
f.state.fail = true;
|
|
68
|
+
await assert.rejects(f.runtime.prepare(claudeInput), /native failure/);
|
|
69
|
+
f.state.fail = false;
|
|
70
|
+
await f.runtime.prepare(claudeInput);
|
|
71
|
+
assert.equal(f.launches.length, 2);
|
|
72
|
+
f.state.stopFails = true;
|
|
73
|
+
await assert.rejects(f.runtime.prepare({ ...claudeInput, systemPrompt: "Updated" }), /cleanup unverified/);
|
|
74
|
+
assert.equal(f.launches.length, 2);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
function codexHost() {
|
|
78
|
+
const state = { status: "idle", process: "process-1", prompt: "Calendar instructions", provider: "openai", fail: false };
|
|
79
|
+
const requests = [];
|
|
80
|
+
const client = { async request(method, input) {
|
|
81
|
+
requests.push({ method, input });
|
|
82
|
+
if (method === "thread/read") return { thread: { historyMode: "paginated", status: state.status, modelProvider: state.provider } };
|
|
83
|
+
if (method === "thread/goal/get") return { goal: null };
|
|
84
|
+
if (method === "thread/inject_items") return {};
|
|
85
|
+
if (method === "thread/loaded/list") return { data: state.status === "notLoaded" ? [] : ["native-history"] };
|
|
86
|
+
if (method === "thread/unsubscribe") { state.status = "notLoaded"; return { status: "unsubscribed" }; }
|
|
87
|
+
if (method === "thread/resume") {
|
|
88
|
+
if (state.fail) throw new Error("resume failed");
|
|
89
|
+
state.status = "idle";
|
|
90
|
+
return { modelProvider: input.modelProvider || state.provider };
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`Unexpected native request: ${method}`);
|
|
93
|
+
} };
|
|
94
|
+
const runtime = createCodexConversationAdapter({ client: async () => client,
|
|
95
|
+
runtime: () => ({ executionId: state.process }), runRequest: (run) => run(),
|
|
96
|
+
readInstructions: () => state.prompt,
|
|
97
|
+
prepareEnvironment: async () => ({ TOOL_ENDPOINT: "current" }),
|
|
98
|
+
prepareHistory: async (params) => params,
|
|
99
|
+
threadParameters: (params) => params,
|
|
100
|
+
verifyEnvironment: async () => {},
|
|
101
|
+
prepareResume: async (id, params) => { requests.push({ method: "host.prepareResume", input: { id } }); return params; }
|
|
102
|
+
});
|
|
103
|
+
return { runtime, state, requests, client };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
test("a standalone Codex consumer installs developer instructions and recovers the same thread without replay", async () => {
|
|
107
|
+
const f = codexHost();
|
|
108
|
+
const params = await f.runtime.prepareInstructions({ cwd: "/calendar", systemPrompt: f.state.prompt });
|
|
109
|
+
assert.equal(params.developerInstructions, f.state.prompt);
|
|
110
|
+
assert.equal(Object.hasOwn(params, "systemPrompt"), false);
|
|
111
|
+
let sent = 0;
|
|
112
|
+
const send = () => { sent += 1; };
|
|
113
|
+
await f.runtime.withThreadContext("native-history", {}, send);
|
|
114
|
+
assert.equal(f.requests.find(r => r.method === "thread/resume").input.developerInstructions, f.state.prompt);
|
|
115
|
+
f.requests.length = 0;
|
|
116
|
+
await f.runtime.withThreadContext("native-history", {}, send);
|
|
117
|
+
assert.equal(f.requests.some(r => r.method === "thread/resume"), false);
|
|
118
|
+
assert.equal(f.requests.some(r => r.method === "thread/inject_items"), false);
|
|
119
|
+
f.state.prompt = "Changed calendar instructions";
|
|
120
|
+
await f.runtime.withThreadContext("native-history", {}, send);
|
|
121
|
+
assert.equal(f.requests.find(r => r.method === "thread/resume").input.threadId, "native-history");
|
|
122
|
+
const revision = f.requests.find(r => r.method === "thread/inject_items").input.items[0];
|
|
123
|
+
assert.equal(revision.role, "developer");
|
|
124
|
+
assert.match(revision.content[0].text, /Changed calendar instructions/);
|
|
125
|
+
assert.equal(sent, 3);
|
|
126
|
+
f.state.fail = true;
|
|
127
|
+
f.state.prompt = "Unavailable installation";
|
|
128
|
+
await assert.rejects(f.runtime.withThreadContext("native-history", {}, send), /could not be verified/);
|
|
129
|
+
assert.equal(sent, 3);
|
|
130
|
+
assert.equal(f.runtime.bindingCount, 0);
|
|
131
|
+
f.state.fail = false;
|
|
132
|
+
await f.runtime.withThreadContext("native-history", {}, () => { sent += 1; throw new Error("ambiguous send"); }).catch(error => {
|
|
133
|
+
assert.match(error.message, /ambiguous send/);
|
|
134
|
+
});
|
|
135
|
+
assert.equal(sent, 4, "an uncertain caller operation is never replayed");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("Codex refreshes restricted persistent conversations without importing host execution resources", async () => {
|
|
139
|
+
const f = codexHost();
|
|
140
|
+
const params = f.runtime.threadParameters({
|
|
141
|
+
...(await f.runtime.prepareInstructions({ systemPrompt: f.state.prompt })),
|
|
142
|
+
config: { shell_environment_policy: { inherit: "none", set: {} } }
|
|
143
|
+
}, {});
|
|
144
|
+
f.runtime.registerThread("restricted", { client: f.client, params, managed: false });
|
|
145
|
+
await f.runtime.withThreadContext("restricted", {}, () => {});
|
|
146
|
+
assert.equal(f.requests.some(r => r.method === "thread/resume"), false);
|
|
147
|
+
f.state.prompt = "Updated restricted instructions";
|
|
148
|
+
await f.runtime.withThreadContext("restricted", {}, () => {});
|
|
149
|
+
const resumed = f.requests.find(r => r.method === "thread/resume").input;
|
|
150
|
+
assert.equal(resumed.developerInstructions, f.state.prompt);
|
|
151
|
+
assert.equal(resumed.config.shell_environment_policy.set.TOOL_ENDPOINT, undefined);
|
|
152
|
+
assert.deepEqual(Object.keys(resumed.config.shell_environment_policy.set), ["JSKIT_CONVERSATION_INSTRUCTIONS"]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("Codex distinguishes native process replacement from a retained loaded thread", async () => {
|
|
156
|
+
const f = codexHost();
|
|
157
|
+
await f.runtime.withThreadContext("history", {}, () => {});
|
|
158
|
+
f.requests.length = 0;
|
|
159
|
+
f.state.process = "process-2";
|
|
160
|
+
f.state.status = "notLoaded";
|
|
161
|
+
await f.runtime.withThreadContext("history", {}, () => {});
|
|
162
|
+
assert.equal(f.requests.filter(r => r.method === "host.prepareResume").length, 1);
|
|
163
|
+
assert.equal(f.requests.filter(r => r.method === "thread/resume").length, 1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("OpenCode restores current system instructions after compaction, changes and failed reads", async () => {
|
|
167
|
+
let version = "one", reads = 0, fail = false;
|
|
168
|
+
const runtime = createOpenCodeConversationAdapter({ resolveInstructions: async (id) => id === "unknown" ? null : ({
|
|
169
|
+
identity: version, placement: "replace", read: async () => { reads += 1; if (fail) throw new Error("owner unavailable"); return `Calendar ${version}`; }
|
|
170
|
+
}) });
|
|
171
|
+
const output = { system: ["Native defaults"] };
|
|
172
|
+
await runtime.transformSystem({ sessionID: "calendar" }, output);
|
|
173
|
+
await runtime.transformSystem({ sessionID: "calendar" }, output);
|
|
174
|
+
assert.equal(reads, 1);
|
|
175
|
+
assert.deepEqual(output.system, ["Calendar one"]);
|
|
176
|
+
version = "two";
|
|
177
|
+
await runtime.transformSystem({ sessionID: "calendar" }, output);
|
|
178
|
+
assert.deepEqual(output.system, ["Calendar two"]);
|
|
179
|
+
runtime.event({ event: { type: "session.compacted", properties: { sessionID: "calendar" } } });
|
|
180
|
+
fail = true;
|
|
181
|
+
await assert.rejects(runtime.transformSystem({ sessionID: "calendar" }, output), /owner unavailable/);
|
|
182
|
+
fail = false;
|
|
183
|
+
await runtime.transformSystem({ sessionID: "calendar" }, output);
|
|
184
|
+
assert.equal(reads, 4);
|
|
185
|
+
const standalone = { system: ["Standalone guidance"] };
|
|
186
|
+
await runtime.transformSystem({ sessionID: "unknown" }, standalone);
|
|
187
|
+
assert.deepEqual(standalone.system, ["Standalone guidance"]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("OpenCode rereads unversioned instructions before each inference without duplicate contributions", async () => {
|
|
191
|
+
let text = "Initial project guidance", reads = 0;
|
|
192
|
+
const runtime = createOpenCodeConversationAdapter({ resolveInstructions: () => ({
|
|
193
|
+
read: () => { reads += 1; return text; }
|
|
194
|
+
}) });
|
|
195
|
+
const output = { system: ["Native defaults"] };
|
|
196
|
+
await runtime.transformSystem({ sessionID: "project" }, output);
|
|
197
|
+
text = "Changed project guidance";
|
|
198
|
+
await runtime.transformSystem({ sessionID: "project" }, output);
|
|
199
|
+
assert.equal(reads, 2);
|
|
200
|
+
assert.deepEqual(output.system, ["Native defaults", text]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("hook delivery distinguishes unclaimed sessions, native placement and a missing declared owner", async () => {
|
|
204
|
+
let prompt = "Complete project and application instructions";
|
|
205
|
+
const deliver = createConversationHookBridge({
|
|
206
|
+
resolveConversation: async ({ providerSessionId }) => providerSessionId === "standalone" ? null : {
|
|
207
|
+
delivery: providerSessionId === "native" ? "native" : "hook"
|
|
208
|
+
},
|
|
209
|
+
readInstructions: async () => { if (!prompt) throw new Error("owner unavailable"); return prompt; }
|
|
210
|
+
});
|
|
211
|
+
assert.deepEqual(await deliver({ scope: "session", providerSessionId: "standalone" }), { kind: "unclaimed" });
|
|
212
|
+
assert.deepEqual(await deliver({ scope: "session", providerSessionId: "native" }), { kind: "delivery", text: "" });
|
|
213
|
+
assert.deepEqual(await deliver({ scope: "session", providerSessionId: "managed" }), { kind: "delivery", text: prompt });
|
|
214
|
+
prompt = "Refreshed at compaction";
|
|
215
|
+
assert.equal((await deliver({ scope: "session", providerSessionId: "managed" })).text, prompt);
|
|
216
|
+
prompt = "";
|
|
217
|
+
await assert.rejects(deliver({ scope: "session", providerSessionId: "managed" }), /owner unavailable/);
|
|
218
|
+
const native = createConversationHookBridge({ resolveConversation: () => ({ delivery: "native" }) });
|
|
219
|
+
assert.deepEqual(await native({ scope: "session" }), { kind: "delivery", text: "" });
|
|
220
|
+
const missing = createConversationHookBridge({ resolveConversation: () => ({ delivery: "hook" }) });
|
|
221
|
+
await assert.rejects(missing({ scope: "session" }), /instruction resolver/);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("OpenCode retries invalid content and rejects an invalidated in-flight read", async () => {
|
|
225
|
+
let content = " ";
|
|
226
|
+
let release;
|
|
227
|
+
const runtime = createOpenCodeConversationAdapter({ resolveInstructions: async () => ({
|
|
228
|
+
identity: "same-source", read: () => content === "pending" ? new Promise(resolve => { release = resolve; }) : content
|
|
229
|
+
}) });
|
|
230
|
+
await assert.rejects(runtime.transformSystem({ sessionID: "one" }, { system: [] }), /must contain text/);
|
|
231
|
+
content = "Current instructions";
|
|
232
|
+
const output = { system: [] };
|
|
233
|
+
await runtime.transformSystem({ sessionID: "one" }, output);
|
|
234
|
+
assert.deepEqual(output.system, [content]);
|
|
235
|
+
content = "pending";
|
|
236
|
+
const inFlight = runtime.transformSystem({ sessionID: "two" }, { system: [] });
|
|
237
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
238
|
+
runtime.invalidate("two");
|
|
239
|
+
release("Obsolete instructions");
|
|
240
|
+
await assert.rejects(inFlight, /context changed while reading/);
|
|
241
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { upgradeConversationRuntimeState } from "../src/server/conversation/runtimeStateUpgrade.js";
|
|
4
|
+
|
|
5
|
+
const configuration = { systemPrompt: "Retained instructions", model: "saved-model" };
|
|
6
|
+
const request = { messageId: "uncertain", text: "Authored text", origin: "user", attachments: [{ attachmentId: "file" }],
|
|
7
|
+
at: "2026-10-02T00:00:00.000Z", seen: { "old/user/": "native-version" }, data: { retained: true } };
|
|
8
|
+
const bindings = {
|
|
9
|
+
api: undefined,
|
|
10
|
+
codex: { threadId: "saved-thread", workdir: "/workspace", configRoot: "/codex", executionId: "saved-execution" },
|
|
11
|
+
claude: { conversationId: "saved-conversation", workdir: "/workspace", configRoot: "/claude", sent: true },
|
|
12
|
+
opencode: { sessionId: "saved-session", workdir: "/workspace", directory: "/private/session", databasePath: "/shared/opencode.db" }
|
|
13
|
+
};
|
|
14
|
+
const segment = (engine, extra = {}) => ({ segmentId: `${engine}-segment`, engine, configuration,
|
|
15
|
+
seen: { "old/user/": "native-version" }, ...(bindings[engine] ? { binding: bindings[engine] } : {}), ...extra });
|
|
16
|
+
const record = (engine = "claude", extra = {}) => ({
|
|
17
|
+
metadata: { application: { retained: true }, runtime: { ...segment(engine), version: 2, predecessors: [], ...extra } },
|
|
18
|
+
conversationLog: [{ turnId: "old", metadata: { runtime: { engine: "codex", segmentId: "codex-segment", status: "interrupted" } },
|
|
19
|
+
messages: [{ role: "user", text: "Accepted before model selection", messageId: "old-message" }] }]
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("offline conversion preserves exact native bindings and authored requests without inventing a prompt", () => {
|
|
23
|
+
const value = record("claude", { predecessors: [segment("codex", { request: structuredClone(request) })] });
|
|
24
|
+
const before = structuredClone(value);
|
|
25
|
+
const result = upgradeConversationRuntimeState(value);
|
|
26
|
+
assert.equal(result.changed, true);
|
|
27
|
+
assert.equal(result.metadata.runtime.version, 3);
|
|
28
|
+
assert.equal(result.metadata.runtime.lastEngine, "codex", "Selection alone is not delivery");
|
|
29
|
+
assert.deepEqual(result.metadata.application, before.metadata.application);
|
|
30
|
+
assert.deepEqual(result.metadata.runtime.binding, before.metadata.runtime.binding);
|
|
31
|
+
assert.deepEqual(result.metadata.runtime.seen, before.metadata.runtime.seen);
|
|
32
|
+
assert.deepEqual(result.metadata.runtime.predecessors[0], { ...before.metadata.runtime.predecessors[0],
|
|
33
|
+
request: { ...request, inspectionOnly: true, threadId: "saved-thread" } });
|
|
34
|
+
assert.equal(Object.hasOwn(result.metadata.runtime.predecessors[0].request, "message"), false);
|
|
35
|
+
assert.equal(Object.hasOwn(result.metadata.runtime.predecessors[0].request, "attempted"), false);
|
|
36
|
+
assert.match(result.warnings[0], /not reconstructed.*not be replayed/);
|
|
37
|
+
assert.deepEqual(value, before, "The transformer does not mutate its source");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("pre-runtime transcripts gain no binding and current converted records are no-ops", () => {
|
|
41
|
+
const metadata = { application: "retained" };
|
|
42
|
+
assert.deepEqual(upgradeConversationRuntimeState({ metadata, conversationLog: [] }), { metadata, changed: false, warnings: [] });
|
|
43
|
+
const value = record("codex", { request: structuredClone(request) });
|
|
44
|
+
const first = upgradeConversationRuntimeState(value);
|
|
45
|
+
const second = upgradeConversationRuntimeState({ ...value, metadata: first.metadata });
|
|
46
|
+
assert.equal(second.changed, false);
|
|
47
|
+
assert.equal(second.metadata, first.metadata);
|
|
48
|
+
assert.deepEqual(second.warnings, []);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("unfinished replacements retain their operation and inspect the destination's exact identity", () => {
|
|
52
|
+
const replacement = { ...segment("opencode"), request: { operationId: "selection", expectedSegmentId: "claude-segment", operation: "select" },
|
|
53
|
+
reason: "engine-change", submission: structuredClone(request), continuity: "Retained context", supersededTurns: [] };
|
|
54
|
+
const value = record("claude", { replacement });
|
|
55
|
+
const result = upgradeConversationRuntimeState(value);
|
|
56
|
+
assert.deepEqual(result.metadata.runtime.replacement, { ...replacement,
|
|
57
|
+
submission: { ...request, inspectionOnly: true, threadId: "saved-session" } });
|
|
58
|
+
assert.deepEqual(value.metadata.runtime.replacement, replacement);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("replacement preparation timestamps stay on the existing operation without backfilling older records", () => {
|
|
62
|
+
for (const preparation of [{}, { preparedAt: "2026-10-03T00:00:00.000Z" }]) {
|
|
63
|
+
const value = record("claude", { replacement: { ...segment("claude", { segmentId: "successor" }),
|
|
64
|
+
request: { operationId: "renewal", expectedSegmentId: "claude-segment", operation: "replace" },
|
|
65
|
+
reason: "renewal", ...preparation }, predecessors: [segment("codex", preparation)] });
|
|
66
|
+
const before = structuredClone(value);
|
|
67
|
+
const result = upgradeConversationRuntimeState(value);
|
|
68
|
+
assert.deepEqual(result.metadata.runtime.replacement, before.metadata.runtime.replacement);
|
|
69
|
+
assert.deepEqual(result.metadata.runtime.predecessors, before.metadata.runtime.predecessors);
|
|
70
|
+
assert.equal(upgradeConversationRuntimeState({ ...value, metadata: result.metadata }).changed, false);
|
|
71
|
+
assert.deepEqual(value, before);
|
|
72
|
+
}
|
|
73
|
+
for (const extra of [{ replacement: { ...segment("claude"), preparedAt: 1 } },
|
|
74
|
+
{ predecessors: [segment("codex", { preparedAt: null })] }]) {
|
|
75
|
+
assert.throws(() => upgradeConversationRuntimeState(record("claude", extra)), { code: "conversation_runtime_upgrade_unavailable" });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("private OpenCode database bindings block preflight in current, retained and replacement state", () => {
|
|
80
|
+
const unsupported = segment("opencode", { binding: { ...bindings.opencode, databasePath: undefined } });
|
|
81
|
+
for (const value of [record("opencode", unsupported), record("claude", { predecessors: [unsupported] }),
|
|
82
|
+
record("claude", { replacement: { ...unsupported, request: { operationId: "pending" } } })]) {
|
|
83
|
+
const before = structuredClone(value);
|
|
84
|
+
assert.throws(() => upgradeConversationRuntimeState(value), /complete offline native-history upgrade.*database was not changed/);
|
|
85
|
+
assert.deepEqual(value, before);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("unfinished historical Undo journals block offline conversion while completed markers remain intact", () => {
|
|
90
|
+
for (const version of [2, 3]) {
|
|
91
|
+
for (const patch of [{ reason: "rewind" }, { request: { operationId: "old", reason: "rewind" } },
|
|
92
|
+
{ request: { operationId: "old", throughTurnId: null } }, { supersededTurns: ["old"] }]) {
|
|
93
|
+
const value = record("api", { version, lastEngine: "api", replacement: { ...segment("api"),
|
|
94
|
+
request: { operationId: "old", reason: "renewal" }, reason: "renewal", ...patch } });
|
|
95
|
+
const before = structuredClone(value);
|
|
96
|
+
assert.throws(() => upgradeConversationRuntimeState(value), /unfinished Undo.*offline.*no history was changed/);
|
|
97
|
+
assert.deepEqual(value, before);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const completed = record("api", { version: 3, lastEngine: "api", predecessors: [segment("api", {
|
|
101
|
+
segmentId: "previous", successorId: "api-segment", replacement: { operationId: "done", reason: "rewind", throughTurnId: null }
|
|
102
|
+
})] });
|
|
103
|
+
completed.conversationLog[0].metadata.runtime.supersededBy = "api-segment";
|
|
104
|
+
const before = structuredClone(completed);
|
|
105
|
+
assert.deepEqual(upgradeConversationRuntimeState(completed), { metadata: completed.metadata, changed: false, warnings: [] });
|
|
106
|
+
assert.deepEqual(completed, before);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("unknown formats and ambiguous pending identities fail without guessing or modifying input", () => {
|
|
110
|
+
for (const value of [record("codex", { version: 1 }),
|
|
111
|
+
record("codex", { binding: { ...bindings.codex, threadId: "" }, request: structuredClone(request) }),
|
|
112
|
+
record("codex", { request: { ...request, message: "Unknown partially converted prompt" } }),
|
|
113
|
+
record("codex", { request: { ...request, attachmentManifest: "Unknown partially converted paths" } })]) {
|
|
114
|
+
const before = structuredClone(value);
|
|
115
|
+
assert.throws(() => upgradeConversationRuntimeState(value), { code: "conversation_runtime_upgrade_unavailable" });
|
|
116
|
+
assert.deepEqual(value, before);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("API reservations keep their admission contract and unattributed history is not native delivery proof", () => {
|
|
121
|
+
const value = record("api", { request: structuredClone(request) });
|
|
122
|
+
value.conversationLog[0].metadata = { application: "legacy" };
|
|
123
|
+
const result = upgradeConversationRuntimeState(value);
|
|
124
|
+
assert.equal(result.metadata.runtime.lastEngine, "");
|
|
125
|
+
assert.deepEqual(result.metadata.runtime.request, request);
|
|
126
|
+
assert.deepEqual(result.warnings, []);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("current journals distinguish an unsent reservation from a frozen attempted native prompt", () => {
|
|
130
|
+
const reserved = record("codex", { version: 3, lastEngine: "codex", binding: { ...bindings.codex, threadId: "" },
|
|
131
|
+
request: { ...request, attempted: false } });
|
|
132
|
+
assert.equal(upgradeConversationRuntimeState(reserved).changed, false);
|
|
133
|
+
const frozen = record("codex", { version: 3, lastEngine: "codex", request: { ...request, attempted: true,
|
|
134
|
+
threadId: "saved-thread", message: "The exact rendered native prompt", displayMessage: request.text,
|
|
135
|
+
displayAttachments: request.attachments, attachmentIds: ["file"], contextText: "The exact private history",
|
|
136
|
+
contextAttachments: [{ attachmentId: "earlier-file" }], attachmentManifest: "\n\nAttached files:\n@file \"name\": \"/authorized/file\"" } });
|
|
137
|
+
assert.equal(upgradeConversationRuntimeState(frozen).changed, false);
|
|
138
|
+
for (const patch of [{ attempted: undefined }, { message: undefined }, { threadId: "another-thread" },
|
|
139
|
+
{ inspectionOnly: false }, { contextAttachments: undefined }, { attachmentManifest: null }]) {
|
|
140
|
+
assert.throws(() => upgradeConversationRuntimeState({ ...frozen,
|
|
141
|
+
metadata: { ...frozen.metadata, runtime: { ...frozen.metadata.runtime,
|
|
142
|
+
request: { ...frozen.metadata.runtime.request, ...patch } } } }), { code: "conversation_runtime_upgrade_unavailable" });
|
|
143
|
+
}
|
|
144
|
+
});
|