@jskit-ai/assistant-core 0.1.204 → 0.1.206
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/fixtures/responsive-assistant/App.vue +12 -2
- package/fixtures/responsive-assistant/ConversationFixture.vue +79 -2
- package/package.json +19 -9
- package/src/client/conversation/AssistantConversationElement.vue +107 -41
- package/src/client/conversation/AssistantTranscript.vue +192 -18
- package/src/server/conversation/claudeClient.js +2 -1
- package/src/server/conversation/claudeTurn.js +16 -7
- package/src/server/conversation/codexConversations.js +94 -0
- package/src/server/conversation/codexProvider.js +15 -5
- package/src/server/conversation/codexProviderOwner.js +70 -47
- package/src/server/conversation/codexProviderSelection.js +5 -2
- package/src/server/conversation/codexRuntimeLifecycle.js +2 -1
- package/src/server/conversation/codexTurn.js +10 -1
- package/src/server/conversation/codexTurnControl.js +1 -1
- package/src/server/conversation/codexTurnRecovery.js +21 -8
- package/src/server/conversation/openCodeRuntime.js +107 -64
- package/src/server/conversation/openCodeTurn.js +33 -16
- package/src/server/conversation/providers/api.js +1 -0
- package/src/server/conversation/providers/claudeDriver.js +13 -0
- package/src/server/conversation/providers/codex.js +90 -47
- package/src/server/conversation/providers/codexDriver.js +4 -1
- package/src/server/conversation/providers/opencodeDriver.js +3 -0
- package/src/server/conversation/runtime.js +32 -5
- package/src/server/hosts/codexEvents.js +15 -0
- package/src/server/hosts/codexTurn.js +5 -0
- package/src/server/hosts/openCodeClient.js +4 -0
- package/src/server/lib/aiConnectionClient.js +9 -11
- package/test/aiConnectionClient.test.js +209 -0
- package/test/assistantScroll.browser.test.js +437 -9
- package/test/claudeClient.test.js +41 -0
- package/test/claudeConversationSession.test.js +78 -0
- package/test/claudeTurn.test.js +18 -0
- package/test/codexConversationSession.test.js +318 -0
- package/test/codexTurn.test.js +203 -2
- package/test/conversationConfiguration.test.js +27 -0
- package/test/conversationReplacement.test.js +76 -1
- package/test/conversationRuntime.test.js +173 -4
- package/test/conversationSession.test.js +139 -0
- package/test/conversationTools.test.js +17 -3
- package/test/opencodeConversationSession.test.js +200 -1
- package/test/packageMetadata.test.js +75 -0
- package/test/serviceToolCatalog.test.js +31 -0
package/test/claudeTurn.test.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
3
4
|
import { createClaudeConversationTurn, claudeNativeMessageId, runClaudeRenewalTurn,
|
|
4
5
|
waitForClaudeConversationTurn } from "../src/server/conversation/claudeTurn.js";
|
|
5
6
|
|
|
@@ -80,6 +81,13 @@ test("lost acknowledgement stays uncertain and stop rejects pending admission",
|
|
|
80
81
|
assert.equal(turn.read().text, "");
|
|
81
82
|
});
|
|
82
83
|
|
|
84
|
+
test("storage failure rejects admission and a slow acknowledged write is not a native timeout", async () => {
|
|
85
|
+
const { turn, send } = fixture();
|
|
86
|
+
await send("slow", { timeoutMs: 5, accept: async () => delay(15) });
|
|
87
|
+
await assert.rejects(send("failed", { accept: async () => { throw new Error("Storage offline"); } }), /Storage offline/);
|
|
88
|
+
turn.stop();
|
|
89
|
+
});
|
|
90
|
+
|
|
83
91
|
test("native session identity and helper output bounds are enforced without truncating answers", async () => {
|
|
84
92
|
const { turn, send } = fixture();
|
|
85
93
|
await assert.rejects(turn.receive({ type: "user", session_id: "another-session" }), /different conversation id/);
|
|
@@ -92,6 +100,16 @@ test("native session identity and helper output bounds are enforced without trun
|
|
|
92
100
|
turn.stop();
|
|
93
101
|
});
|
|
94
102
|
|
|
103
|
+
test("a native error result is failure information rather than an assistant answer", async () => {
|
|
104
|
+
const { turn, send, events } = fixture();
|
|
105
|
+
await send();
|
|
106
|
+
await turn.receive({ type: "result", subtype: "error_during_execution", is_error: true, result: "The model connection failed." });
|
|
107
|
+
assert.equal(turn.read().status, "failed");
|
|
108
|
+
assert.equal(turn.read().error, "The model connection failed.");
|
|
109
|
+
assert.equal(turn.read().text, "");
|
|
110
|
+
assert.equal(events.some(event => event.type === "message"), false);
|
|
111
|
+
});
|
|
112
|
+
|
|
95
113
|
function renewalFixture() {
|
|
96
114
|
const input = { clientMessageId: "renewal-operation", prompt: "Approved prompt", timeoutMs: 100 };
|
|
97
115
|
const uuid = claudeNativeMessageId(input.clientMessageId);
|
|
@@ -2126,3 +2126,321 @@ test("Codex goal controls reject local attachments before native work", async t
|
|
|
2126
2126
|
assert.equal((await f.conversation.read()).conversationLog.length, 0);
|
|
2127
2127
|
await assert.rejects(readFile(path.join(f.directory, "trace.jsonl")), { code: "ENOENT" });
|
|
2128
2128
|
});
|
|
2129
|
+
|
|
2130
|
+
|
|
2131
|
+
for (const change of ["model", "effort"]) test(`Codex honors a consumer's fresh native policy for ${change} changes without retiring its shared peer`, async t => {
|
|
2132
|
+
const f = await fixture(t);
|
|
2133
|
+
const binding = (id = "conversation") => f.storage.read(id, async tx => (await tx.readMetadata()).runtime.binding);
|
|
2134
|
+
const peer = await f.first.open({ id: "peer", configuration });
|
|
2135
|
+
await peer.send({ messageId: "peer-first", text: "Peer history" });
|
|
2136
|
+
await peer.wait();
|
|
2137
|
+
const peerBinding = await binding("peer");
|
|
2138
|
+
await f.conversation.send(input);
|
|
2139
|
+
const original = await f.conversation.wait();
|
|
2140
|
+
const previous = await binding();
|
|
2141
|
+
const beforeSelection = await f.trace();
|
|
2142
|
+
const oldHistory = JSON.parse(await readFile(path.join(f.directory, "history.json"), "utf8")).threads.find(thread => thread.id === previous.threadId);
|
|
2143
|
+
const selected = { ...configuration, [change]: change === "model" ? "another-model" : "low" };
|
|
2144
|
+
const request = { operationId: `fresh-${change}`, expectedSegmentId: original.segmentId,
|
|
2145
|
+
engine: "codex", configuration: selected, retireNative: true };
|
|
2146
|
+
const receipt = await f.conversation.select(request);
|
|
2147
|
+
assert.notEqual(receipt.segmentId, original.segmentId);
|
|
2148
|
+
const inert = await binding();
|
|
2149
|
+
assert.equal(inert.threadId, "");
|
|
2150
|
+
assert.equal(inert.configRoot, previous.configRoot);
|
|
2151
|
+
assert.equal(inert.workdir, previous.workdir);
|
|
2152
|
+
const predecessor = await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.predecessors.find(segment => segment.segmentId === original.segmentId));
|
|
2153
|
+
assert.equal(predecessor.binding.threadId, previous.threadId);
|
|
2154
|
+
const selectedTrace = await f.trace();
|
|
2155
|
+
assert.deepEqual(selectedTrace.filter(row => row.args || ["thread/start", "turn/start"].includes(row.method)),
|
|
2156
|
+
beforeSelection.filter(row => row.args || ["thread/start", "turn/start"].includes(row.method)),
|
|
2157
|
+
"Selecting inspects readiness without creating a thread, process or inference");
|
|
2158
|
+
const retained = await f.conversation.read();
|
|
2159
|
+
assert.equal(retained.id, original.id);
|
|
2160
|
+
assert.deepEqual(retained.conversationLog, original.conversationLog);
|
|
2161
|
+
assert.equal((await f.conversation.select(request)).duplicate, true);
|
|
2162
|
+
await f.conversation.send({ messageId: "changed", text: "Continue after the change" });
|
|
2163
|
+
assert.equal((await f.conversation.wait()).conversationLog.at(-1).metadata.runtime.status, "complete");
|
|
2164
|
+
const fresh = await binding();
|
|
2165
|
+
assert.ok(fresh.threadId);
|
|
2166
|
+
assert.notEqual(fresh.threadId, previous.threadId);
|
|
2167
|
+
assert.equal(fresh.accountIdentity, previous.accountIdentity);
|
|
2168
|
+
assert.deepEqual(JSON.parse(await readFile(path.join(f.directory, "history.json"), "utf8")).threads.find(thread => thread.id === previous.threadId), oldHistory);
|
|
2169
|
+
let trace = await f.trace();
|
|
2170
|
+
assert.equal(trace.filter(row => row.args).length, 1, "A peer retains the original shared account service");
|
|
2171
|
+
assert.equal(trace.filter(row => row.method === "thread/start").length, 3);
|
|
2172
|
+
const calls = trace.filter(row => row.method === "turn/start");
|
|
2173
|
+
assert.equal(calls.length, 3);
|
|
2174
|
+
assert.equal(calls[2].params.threadId, fresh.threadId);
|
|
2175
|
+
assert.equal(calls[2].params.model, selected.model);
|
|
2176
|
+
assert.equal(calls[2].params.effort, selected.effort);
|
|
2177
|
+
const handedHistory = JSON.parse(calls[2].params.input[0].text.split("\n").find(line => line.startsWith('{"messages":')));
|
|
2178
|
+
assert.deepEqual(handedHistory.messages.map(({ role, text }) => [role, text]), [["user", "Hello"], ["assistant", "Answer: Hello"]]);
|
|
2179
|
+
assert.deepEqual(handedHistory.removedMessageIds, []);
|
|
2180
|
+
assert.equal(calls[2].params.input[0].text.split("User's message:\n")[1], "Continue after the change");
|
|
2181
|
+
assert.match(calls[2].params.input[0].text, /Hello/);
|
|
2182
|
+
assert.match(calls[2].params.input[0].text, /Answer: Hello/);
|
|
2183
|
+
assert.match(calls[2].params.input[0].text, /Continue after the change/);
|
|
2184
|
+
assert.doesNotMatch(calls[2].params.input[0].text, /Peer history/);
|
|
2185
|
+
await peer.send({ messageId: "peer-next", text: "Peer continues" });
|
|
2186
|
+
assert.equal((await peer.wait()).conversationLog.length, 2);
|
|
2187
|
+
assert.equal((await binding("peer")).threadId, peerBinding.threadId);
|
|
2188
|
+
trace = await f.trace();
|
|
2189
|
+
assert.equal(trace.filter(row => row.args).length, 1);
|
|
2190
|
+
assert.equal(trace.filter(row => row.method === "turn/start").at(-1).params.input[0].text, "Peer continues");
|
|
2191
|
+
await f.first.close();
|
|
2192
|
+
const resumed = await f.runtime().open({ id: "conversation" });
|
|
2193
|
+
assert.equal((await resumed.select(request)).duplicate, true);
|
|
2194
|
+
assert.equal((await resumed.send(input)).duplicate, true);
|
|
2195
|
+
assert.equal((await binding()).threadId, fresh.threadId);
|
|
2196
|
+
await resumed.send({ messageId: "after-restart", text: "Continue after restart" });
|
|
2197
|
+
assert.equal((await resumed.wait()).conversationLog.length, 3);
|
|
2198
|
+
trace = await f.trace();
|
|
2199
|
+
assert.equal(trace.filter(row => row.args).length, 2);
|
|
2200
|
+
assert.equal(trace.filter(row => row.method === "thread/start").length, 3);
|
|
2201
|
+
assert.equal(trace.filter(row => row.method === "thread/resume").at(-1).params.threadId, fresh.threadId);
|
|
2202
|
+
assert.equal(trace.filter(row => row.method === "turn/start").at(-1).params.input[0].text, "Continue after restart");
|
|
2203
|
+
const returned = await resumed.select({ operationId: `restore-${change}`, expectedSegmentId: (await resumed.read()).segmentId,
|
|
2204
|
+
engine: "codex", configuration, retireNative: true });
|
|
2205
|
+
assert.notEqual(returned.segmentId, original.segmentId, "The consumer policy cannot restore an older retained segment");
|
|
2206
|
+
assert.notEqual(returned.segmentId, receipt.segmentId);
|
|
2207
|
+
await resumed.send({ messageId: "returned", text: "Back to the original settings" });
|
|
2208
|
+
assert.equal((await resumed.wait()).conversationLog.length, 4);
|
|
2209
|
+
const restored = await binding();
|
|
2210
|
+
assert.notEqual(restored.threadId, previous.threadId);
|
|
2211
|
+
assert.notEqual(restored.threadId, fresh.threadId);
|
|
2212
|
+
assert.equal(restored.accountIdentity, previous.accountIdentity);
|
|
2213
|
+
const restoredPrompt = (await f.trace()).filter(row => row.method === "turn/start").at(-1).params.input[0].text;
|
|
2214
|
+
assert.match(restoredPrompt, /Hello/);
|
|
2215
|
+
assert.match(restoredPrompt, /Continue after the change/);
|
|
2216
|
+
assert.match(restoredPrompt, /Continue after restart/);
|
|
2217
|
+
});
|
|
2218
|
+
|
|
2219
|
+
test("held lifecycle catalogue work does not block another owner's runtime stop or acquisition", async t => {
|
|
2220
|
+
const root = await mkdtemp(path.join(tmpdir(), "jskit-codex-lifecycle-"));
|
|
2221
|
+
const first = createCodexAppServerProviderOwner({ runtimeRoot: path.join(root, "a") });
|
|
2222
|
+
const other = createCodexAppServerProviderOwner({ runtimeRoot: path.join(root, "b") });
|
|
2223
|
+
const entered = Promise.withResolvers();
|
|
2224
|
+
const release = Promise.withResolvers();
|
|
2225
|
+
const events = [];
|
|
2226
|
+
const firstOptions = { runtimeDir: path.join(root, "a", "runtime") };
|
|
2227
|
+
const stopOptions = { runtimeDir: path.join(root, "b", "stop") };
|
|
2228
|
+
const acquireOptions = { runtimeDir: path.join(root, "b", "acquire") };
|
|
2229
|
+
t.after(async () => {
|
|
2230
|
+
for (const owner of [first, other]) await owner.invalidateRuntimes({ includeOwned: true, stopOwnedRuntimes: true });
|
|
2231
|
+
await rm(root, { recursive: true, force: true });
|
|
2232
|
+
});
|
|
2233
|
+
const firstProvider = first.createProvider({ providerKey: "a", providerOptions: firstOptions, create: () => ({
|
|
2234
|
+
async currentRuntimeInfo() { return { runtimeDir: firstOptions.runtimeDir }; },
|
|
2235
|
+
async listModels() { entered.resolve(); await release.promise; events.push("a-catalogue-finished"); return { data: [] }; },
|
|
2236
|
+
async stopRuntime() { events.push("a-stop"); return { stopped: true }; },
|
|
2237
|
+
close() {}
|
|
2238
|
+
}) });
|
|
2239
|
+
await first.acquireRuntime({ providerKey: "a", providerOptions: firstOptions, provider: firstProvider, operation: async () => {} });
|
|
2240
|
+
other.createProvider({ providerKey: "b-stop", providerOptions: stopOptions, create: () => ({
|
|
2241
|
+
async stopRuntime() { events.push("b-stop"); return { stopped: true }; }, close() {}
|
|
2242
|
+
}) });
|
|
2243
|
+
const acquiredProvider = other.createProvider({ providerKey: "b-acquire", providerOptions: acquireOptions, create: () => ({
|
|
2244
|
+
async ensureRuntime() { events.push("b-acquire"); return {}; },
|
|
2245
|
+
async stopRuntime() { return { stopped: true }; }, close() {}
|
|
2246
|
+
}) });
|
|
2247
|
+
const catalogue = first.readModelCatalog({ prepareProviderOptions: async () => firstOptions,
|
|
2248
|
+
providerFactory() { throw new Error("The existing provider must be reused."); } });
|
|
2249
|
+
await entered.promise;
|
|
2250
|
+
const stopping = other.stopCachedProvider("b-stop", { requireStopped: true });
|
|
2251
|
+
const acquiring = other.ensureSession({ sessionId: "b", providerKey: "b-acquire", providerOptions: acquireOptions,
|
|
2252
|
+
assertAdmission() {} });
|
|
2253
|
+
let deadline;
|
|
2254
|
+
try {
|
|
2255
|
+
const [stopped, acquired] = await Promise.race([
|
|
2256
|
+
Promise.all([stopping, acquiring]),
|
|
2257
|
+
new Promise((_, reject) => { deadline = setTimeout(() => reject(new Error("An unrelated runtime waited for the held catalogue.")), 2000); })
|
|
2258
|
+
]);
|
|
2259
|
+
assert.equal(stopped.stopped, true);
|
|
2260
|
+
assert.equal(acquired, acquiredProvider);
|
|
2261
|
+
assert.deepEqual(events, ["b-stop", "b-acquire"]);
|
|
2262
|
+
assert.equal(first.providers.get("a"), firstProvider);
|
|
2263
|
+
} finally {
|
|
2264
|
+
clearTimeout(deadline);
|
|
2265
|
+
release.resolve();
|
|
2266
|
+
await Promise.allSettled([catalogue, stopping, acquiring]);
|
|
2267
|
+
}
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
test("held lifecycle catalogue work preserves same-runtime peer ordering and the final participant stop", async t => {
|
|
2271
|
+
const root = await mkdtemp(path.join(tmpdir(), "jskit-codex-lifecycle-peer-"));
|
|
2272
|
+
const first = createCodexAppServerProviderOwner({ runtimeRoot: root });
|
|
2273
|
+
const peer = createCodexAppServerProviderOwner({ runtimeRoot: root });
|
|
2274
|
+
const providerOptions = { runtimeDir: path.join(root, "runtime") };
|
|
2275
|
+
const entered = Promise.withResolvers();
|
|
2276
|
+
const release = Promise.withResolvers();
|
|
2277
|
+
const events = [];
|
|
2278
|
+
t.after(async () => {
|
|
2279
|
+
for (const owner of [first, peer]) await owner.invalidateRuntimes({ includeOwned: true, stopOwnedRuntimes: true });
|
|
2280
|
+
await rm(root, { recursive: true, force: true });
|
|
2281
|
+
});
|
|
2282
|
+
const provider = first.createProvider({ providerKey: "first", providerOptions, create: () => ({
|
|
2283
|
+
async currentRuntimeInfo() { return { runtimeDir: providerOptions.runtimeDir }; },
|
|
2284
|
+
async listModels() { entered.resolve(); await release.promise; events.push("catalogue"); return { data: [] }; },
|
|
2285
|
+
async stopRuntime() { events.push("first-stop"); return { stopped: true }; }, close() {}
|
|
2286
|
+
}) });
|
|
2287
|
+
await first.acquireRuntime({ providerKey: "first", providerOptions, provider, operation: async () => {} });
|
|
2288
|
+
peer.createProvider({ providerKey: "peer", providerOptions, create: () => ({
|
|
2289
|
+
async stopRuntime() { events.push("peer-stop"); return { stopped: true }; },
|
|
2290
|
+
close() { events.push("peer-close"); }
|
|
2291
|
+
}) });
|
|
2292
|
+
const catalogue = first.readModelCatalog({ prepareProviderOptions: async () => providerOptions,
|
|
2293
|
+
providerFactory() { throw new Error("The existing provider must be reused."); } });
|
|
2294
|
+
await entered.promise;
|
|
2295
|
+
let finished = false;
|
|
2296
|
+
const stopping = peer.stopCachedProvider("peer", { requireStopped: true }).then(value => { finished = true; return value; });
|
|
2297
|
+
try {
|
|
2298
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
2299
|
+
assert.equal(finished, false);
|
|
2300
|
+
assert.deepEqual(events, []);
|
|
2301
|
+
} finally {
|
|
2302
|
+
release.resolve();
|
|
2303
|
+
await catalogue;
|
|
2304
|
+
}
|
|
2305
|
+
const stopped = await stopping;
|
|
2306
|
+
assert.equal(stopped.sharedProcessRetained, true);
|
|
2307
|
+
assert.equal(stopped.stopped, false);
|
|
2308
|
+
assert.equal(first.providers.get("first"), provider);
|
|
2309
|
+
assert.equal(peer.providers.get("peer"), undefined);
|
|
2310
|
+
assert.deepEqual(events, ["catalogue", "peer-close"]);
|
|
2311
|
+
assert.equal((await first.stopCachedProvider("first", { requireStopped: true })).stopped, true);
|
|
2312
|
+
assert.deepEqual(events, ["catalogue", "peer-close", "first-stop"]);
|
|
2313
|
+
});
|
|
2314
|
+
|
|
2315
|
+
test("held lifecycle catalogue work preserves one owner's FIFO across different runtimes", async t => {
|
|
2316
|
+
const root = await mkdtemp(path.join(tmpdir(), "jskit-codex-lifecycle-fifo-"));
|
|
2317
|
+
const owner = createCodexAppServerProviderOwner({ runtimeRoot: root });
|
|
2318
|
+
const providerOptions = { runtimeDir: path.join(root, "catalogue") };
|
|
2319
|
+
const entered = Promise.withResolvers();
|
|
2320
|
+
const release = Promise.withResolvers();
|
|
2321
|
+
const events = [];
|
|
2322
|
+
t.after(async () => {
|
|
2323
|
+
await owner.invalidateRuntimes({ includeOwned: true, stopOwnedRuntimes: true });
|
|
2324
|
+
await rm(root, { recursive: true, force: true });
|
|
2325
|
+
});
|
|
2326
|
+
const provider = owner.createProvider({ providerKey: "catalogue", providerOptions, create: () => ({
|
|
2327
|
+
async currentRuntimeInfo() { return { runtimeDir: providerOptions.runtimeDir }; },
|
|
2328
|
+
async listModels() { entered.resolve(); await release.promise; events.push("catalogue"); return { data: [] }; },
|
|
2329
|
+
async stopRuntime() { return { stopped: true }; }, close() {}
|
|
2330
|
+
}) });
|
|
2331
|
+
await owner.acquireRuntime({ providerKey: "catalogue", providerOptions, provider, operation: async () => {} });
|
|
2332
|
+
owner.createProvider({ providerKey: "other", providerOptions: { runtimeDir: path.join(root, "other") }, create: () => ({
|
|
2333
|
+
async stopRuntime() { events.push("other-stop"); return { stopped: true }; }, close() {}
|
|
2334
|
+
}) });
|
|
2335
|
+
const catalogue = owner.readModelCatalog({ prepareProviderOptions: async () => providerOptions,
|
|
2336
|
+
providerFactory() { throw new Error("The existing provider must be reused."); } });
|
|
2337
|
+
await entered.promise;
|
|
2338
|
+
let finished = false;
|
|
2339
|
+
const stopping = owner.stopCachedProvider("other", { requireStopped: true }).then(value => { finished = true; return value; });
|
|
2340
|
+
try {
|
|
2341
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
2342
|
+
assert.equal(finished, false);
|
|
2343
|
+
assert.deepEqual(events, []);
|
|
2344
|
+
} finally {
|
|
2345
|
+
release.resolve();
|
|
2346
|
+
await catalogue;
|
|
2347
|
+
}
|
|
2348
|
+
assert.equal((await stopping).stopped, true);
|
|
2349
|
+
assert.deepEqual(events, ["catalogue", "other-stop"]);
|
|
2350
|
+
});
|
|
2351
|
+
|
|
2352
|
+
test("held lifecycle catalogue preparation remains tracked through shutdown before its runtime key is known", async t => {
|
|
2353
|
+
const root = await mkdtemp(path.join(tmpdir(), "jskit-codex-lifecycle-shutdown-"));
|
|
2354
|
+
let closing = false;
|
|
2355
|
+
const owner = createCodexAppServerProviderOwner({ runtimeRoot: root,
|
|
2356
|
+
assertOpen() { if (closing) throw new Error("The application is shutting down."); } });
|
|
2357
|
+
const entered = Promise.withResolvers();
|
|
2358
|
+
const release = Promise.withResolvers();
|
|
2359
|
+
const events = [];
|
|
2360
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
2361
|
+
const catalogue = owner.readModelCatalog({
|
|
2362
|
+
async prepareProviderOptions() { entered.resolve(); await release.promise; return { runtimeDir: path.join(root, "runtime") }; },
|
|
2363
|
+
providerFactory() {
|
|
2364
|
+
return {
|
|
2365
|
+
async currentRuntimeInfo() { return {}; },
|
|
2366
|
+
async ensureRuntime() { events.push("acquire"); return { reused: false }; },
|
|
2367
|
+
async listModels() { events.push("catalogue"); return { data: [] }; },
|
|
2368
|
+
close() { events.push("close"); }
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
2371
|
+
});
|
|
2372
|
+
await entered.promise;
|
|
2373
|
+
assert.equal(owner.lifecycleTasks.size, 1);
|
|
2374
|
+
closing = true;
|
|
2375
|
+
owner.beginShutdown();
|
|
2376
|
+
let drained = false;
|
|
2377
|
+
const draining = Promise.allSettled([...owner.lifecycleTasks]).then(() => { drained = true; });
|
|
2378
|
+
try {
|
|
2379
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
2380
|
+
assert.equal(drained, false);
|
|
2381
|
+
assert.deepEqual(events, []);
|
|
2382
|
+
} finally {
|
|
2383
|
+
release.resolve();
|
|
2384
|
+
}
|
|
2385
|
+
await assert.rejects(catalogue, /application is shutting down/);
|
|
2386
|
+
await draining;
|
|
2387
|
+
assert.equal(drained, true);
|
|
2388
|
+
assert.equal(owner.lifecycleTasks.size, 0);
|
|
2389
|
+
assert.deepEqual(events, ["close"]);
|
|
2390
|
+
});
|
|
2391
|
+
|
|
2392
|
+
test("held lifecycle catalogue rejection preserves a newer same-runtime tail and later owner work", async t => {
|
|
2393
|
+
const root = await mkdtemp(path.join(tmpdir(), "jskit-codex-lifecycle-rejection-"));
|
|
2394
|
+
const owners = [0, 1, 2].map(() => createCodexAppServerProviderOwner({ runtimeRoot: root }));
|
|
2395
|
+
const providerOptions = { runtimeDir: path.join(root, "runtime") };
|
|
2396
|
+
const entered = Promise.withResolvers();
|
|
2397
|
+
const release = Promise.withResolvers();
|
|
2398
|
+
const nextEntered = Promise.withResolvers();
|
|
2399
|
+
const nextRelease = Promise.withResolvers();
|
|
2400
|
+
const failure = new Error("Catalogue unavailable.");
|
|
2401
|
+
const catalogue = { data: [{ id: "model" }] };
|
|
2402
|
+
let calls = 0;
|
|
2403
|
+
t.after(async () => {
|
|
2404
|
+
for (const owner of owners) await owner.invalidateRuntimes({ includeOwned: true, stopOwnedRuntimes: true });
|
|
2405
|
+
await rm(root, { recursive: true, force: true });
|
|
2406
|
+
});
|
|
2407
|
+
const provider = owners[0].createProvider({ providerKey: "catalogue", providerOptions, create: () => ({
|
|
2408
|
+
async currentRuntimeInfo() { return { runtimeDir: providerOptions.runtimeDir }; },
|
|
2409
|
+
async listModels() {
|
|
2410
|
+
calls += 1;
|
|
2411
|
+
if (calls === 1) { entered.resolve(); await release.promise; throw failure; }
|
|
2412
|
+
if (calls === 2) { nextEntered.resolve(); await nextRelease.promise; }
|
|
2413
|
+
return catalogue;
|
|
2414
|
+
},
|
|
2415
|
+
async stopRuntime() { return { stopped: true }; }, close() {}
|
|
2416
|
+
}) });
|
|
2417
|
+
await owners[0].acquireRuntime({ providerKey: "catalogue", providerOptions, provider, operation: async () => {} });
|
|
2418
|
+
const options = { prepareProviderOptions: async () => providerOptions,
|
|
2419
|
+
providerFactory() { throw new Error("The existing provider must be reused."); } };
|
|
2420
|
+
const failed = assert.rejects(owners[0].readModelCatalog(options), error => error === failure);
|
|
2421
|
+
await entered.promise;
|
|
2422
|
+
const next = owners[1].readModelCatalog(options);
|
|
2423
|
+
let last;
|
|
2424
|
+
try {
|
|
2425
|
+
release.resolve();
|
|
2426
|
+
await failed;
|
|
2427
|
+
await nextEntered.promise;
|
|
2428
|
+
let finished = false;
|
|
2429
|
+
last = owners[2].readModelCatalog(options).then(value => { finished = true; return value; });
|
|
2430
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
2431
|
+
assert.equal(finished, false);
|
|
2432
|
+
assert.equal(calls, 2, "The rejected task cannot remove the newer runtime tail.");
|
|
2433
|
+
nextRelease.resolve();
|
|
2434
|
+
assert.equal(await next, catalogue);
|
|
2435
|
+
assert.equal(await last, catalogue);
|
|
2436
|
+
assert.equal(calls, 3);
|
|
2437
|
+
assert.equal(await owners[0].readModelCatalog(options), catalogue);
|
|
2438
|
+
assert.equal(calls, 4, "The original owner's queue also continues after rejection.");
|
|
2439
|
+
await Promise.all(owners.flatMap(owner => [...owner.lifecycleTasks]));
|
|
2440
|
+
assert.deepEqual(owners.map(owner => owner.lifecycleTasks.size), [0, 0, 0]);
|
|
2441
|
+
} finally {
|
|
2442
|
+
release.resolve();
|
|
2443
|
+
nextRelease.resolve();
|
|
2444
|
+
await Promise.allSettled([failed, next, last]);
|
|
2445
|
+
}
|
|
2446
|
+
});
|
package/test/codexTurn.test.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
3
5
|
import test from "node:test";
|
|
4
6
|
import { createMemoryConversationStorage } from "../src/server/conversation/memoryStorage.js";
|
|
5
7
|
import { createConversationTranscript } from "../src/server/conversation/transcript.js";
|
|
6
8
|
import { createConversationStreams } from "../src/server/conversation/streams.js";
|
|
7
|
-
import { sendCodexAppServerPrompt } from "../src/server/conversation/codexProvider.js";
|
|
9
|
+
import { sendCodexAppServerPrompt, CodexAppServerAgentProvider } from "../src/server/conversation/codexProvider.js";
|
|
10
|
+
import { createCodexHelperThreadLedgerOwner } from "../src/server/conversation/codexHelperThreadLedger.js";
|
|
8
11
|
import {
|
|
9
12
|
waitForCodexAppServerTurn,
|
|
10
13
|
createCodexAppServerRunOwner,
|
|
@@ -60,6 +63,119 @@ function fixture(turns = []) {
|
|
|
60
63
|
return { provider, listeners, operations };
|
|
61
64
|
}
|
|
62
65
|
|
|
66
|
+
// Joining fixture for the original detached coordinator. Native observation is
|
|
67
|
+
// the same provider/watcher fixture above; the host only prepares its policy.
|
|
68
|
+
async function detachedFixture(t, { refused = false, unavailable = null, contextError = null, dispatchError = null } = {}) {
|
|
69
|
+
const f = fixture([turn("turn-1", "completed", "Original detached answer.")]);
|
|
70
|
+
const stateRoot = await mkdtemp(join(tmpdir(), "codex-detached-"));
|
|
71
|
+
t.after(() => rm(stateRoot, { recursive: true, force: true }));
|
|
72
|
+
const runtime = { stateRoot };
|
|
73
|
+
const session = { sessionId: "session-1" };
|
|
74
|
+
const threadSettings = { model: "selected-model", cwd: "/authorized/workdir" };
|
|
75
|
+
const turnSettings = { model: "selected-model", effort: "high", cwd: "/authorized/workdir" };
|
|
76
|
+
f.events = [];
|
|
77
|
+
f.releases = 0;
|
|
78
|
+
Object.assign(f.provider, {
|
|
79
|
+
async resumeThread(id, settings) {
|
|
80
|
+
assert.equal(id, "thread-1");
|
|
81
|
+
assert.equal(settings, threadSettings);
|
|
82
|
+
f.operations.push("resume");
|
|
83
|
+
return { id };
|
|
84
|
+
},
|
|
85
|
+
async startThread(settings) {
|
|
86
|
+
assert.deepEqual(settings, { ...threadSettings, ephemeral: true });
|
|
87
|
+
f.operations.push("create");
|
|
88
|
+
return { response: { thread: { id: "thread-1" } } };
|
|
89
|
+
},
|
|
90
|
+
async sendTurn(id, input, settings) {
|
|
91
|
+
assert.equal(id, "thread-1");
|
|
92
|
+
assert.deepEqual(input, ["Original prompt"]);
|
|
93
|
+
assert.deepEqual(settings, turnSettings);
|
|
94
|
+
f.operations.push("dispatch");
|
|
95
|
+
if (dispatchError) throw dispatchError;
|
|
96
|
+
return turn("turn-1", "completed");
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
const conversationPreparation = {
|
|
100
|
+
detached(id) {
|
|
101
|
+
assert.equal(id, "session-1");
|
|
102
|
+
f.operations.push("admission");
|
|
103
|
+
return {
|
|
104
|
+
admission: refused ? { ok: false, code: "original_closing" } : { ok: true, release() { f.releases++; f.operations.push("release"); } },
|
|
105
|
+
get result() { f.operations.push("input"); return unavailable; },
|
|
106
|
+
get prompt() { return "Original prompt"; },
|
|
107
|
+
execution(context) {
|
|
108
|
+
assert.equal(context.provider, f.provider);
|
|
109
|
+
return { threadPreparation: { settings: () => threadSettings }, authorized: { turnSettings },
|
|
110
|
+
failure(error, status) { assert.equal(status, ""); return error; } };
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
context(id, input, options) {
|
|
115
|
+
f.operations.push("context");
|
|
116
|
+
assert.equal(id, "session-1");
|
|
117
|
+
assert.equal(options, f.options);
|
|
118
|
+
if (contextError) throw contextError;
|
|
119
|
+
return { context: { session }, helperTurn: false, projectRuntimeRoot: stateRoot, providerOptions: {},
|
|
120
|
+
project: provider => ({ provider, runtime, session, workdir: "/authorized/workdir", ok: true }) };
|
|
121
|
+
},
|
|
122
|
+
admissionError() { return null; },
|
|
123
|
+
failure(error) { return { ok: false, error: error.message, code: error.code }; }
|
|
124
|
+
};
|
|
125
|
+
f.options = { runtime, session, onEvent(event) {
|
|
126
|
+
f.events.push(event);
|
|
127
|
+
f.operations.push(`event:${event.type}`);
|
|
128
|
+
return f.onEvent?.(event);
|
|
129
|
+
} };
|
|
130
|
+
f.owner = createCodexAppServerRunOwner({ conversationPreparation,
|
|
131
|
+
helperThreads: { ledgerOwner: createCodexHelperThreadLedgerOwner({ executionProfile: value => structuredClone(value) }) },
|
|
132
|
+
providerSessions: { owner: { providers: new Map(), ensureSession: async () => f.provider }, context: () => ({}) }
|
|
133
|
+
});
|
|
134
|
+
return f;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
test("original detached run coordinator joins ordinary create/resume, events and native completion without awaiting observers", async t => {
|
|
138
|
+
for (const resume of [false, true]) await t.test(resume ? "resume" : "create", { timeout: 2_000 }, async t => {
|
|
139
|
+
const f = await detachedFixture(t);
|
|
140
|
+
const neverAwaited = Promise.withResolvers();
|
|
141
|
+
f.onEvent = () => neverAwaited.promise;
|
|
142
|
+
const result = await f.owner.runDetachedConversation("session-1", {
|
|
143
|
+
prompt: "Original prompt", ...(resume ? { codexSessionId: "thread-1" } : { ephemeral: true })
|
|
144
|
+
}, f.options);
|
|
145
|
+
assert.deepEqual(result, { ok: true, text: "Original detached answer.", threadId: "thread-1", turnId: "turn-1" });
|
|
146
|
+
assert.deepEqual(f.events, [
|
|
147
|
+
{ type: "thread", threadId: "thread-1" },
|
|
148
|
+
{ type: "turn", status: "completed", threadId: "thread-1", turnId: "turn-1" },
|
|
149
|
+
{ type: "completed", status: "completed", text: "Original detached answer.", threadId: "thread-1", turnId: "turn-1" }
|
|
150
|
+
]);
|
|
151
|
+
assert.deepEqual(f.operations, ["admission", "input", "context", resume ? "resume" : "create",
|
|
152
|
+
"event:thread", "subscribe", "dispatch", "event:turn", "read", "unsubscribe", "event:completed", "release"]);
|
|
153
|
+
assert.equal(f.releases, 1);
|
|
154
|
+
assert.equal(f.listeners.size, 0);
|
|
155
|
+
assert.equal(f.owner.conversations.size, 0, "Detached execution does not retain a Main/scoped conversation.");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("original detached admission/result boundary releases every early or dispatched failure", async t => {
|
|
160
|
+
const failure = Object.assign(new Error("Original native failure"), { code: "original_native_failure" });
|
|
161
|
+
for (const scenario of ["refused", "disabled", "empty", "context", "thread-event", "dispatch"]) await t.test(scenario, async t => {
|
|
162
|
+
const unavailable = scenario === "disabled" ? { ok: false, code: "original_disabled" }
|
|
163
|
+
: scenario === "empty" ? { ok: false, code: "original_empty" } : null;
|
|
164
|
+
const f = await detachedFixture(t, { refused: scenario === "refused", unavailable,
|
|
165
|
+
contextError: scenario === "context" ? failure : null, dispatchError: scenario === "dispatch" ? failure : null });
|
|
166
|
+
if (scenario === "thread-event") f.onEvent = () => { throw failure; };
|
|
167
|
+
const result = await f.owner.runDetachedConversation("session-1", { prompt: "Original prompt", ephemeral: true }, f.options);
|
|
168
|
+
assert.deepEqual(result, scenario === "refused" ? { ok: false, code: "original_closing" }
|
|
169
|
+
: unavailable || { ok: false, error: failure.message, code: failure.code });
|
|
170
|
+
assert.equal(f.releases, scenario === "refused" ? 0 : 1);
|
|
171
|
+
assert.equal(f.listeners.size, 0);
|
|
172
|
+
assert.equal(f.owner.conversations.size, 0);
|
|
173
|
+
if (["refused", "disabled", "empty"].includes(scenario)) assert.equal(f.operations.includes("context"), false);
|
|
174
|
+
if (scenario !== "dispatch") assert.equal(f.operations.includes("dispatch"), false);
|
|
175
|
+
if (scenario === "dispatch") assert.equal(f.operations.includes("unsubscribe"), true);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
63
179
|
test("an already completed exact turn returns its saved answer without subscribing or rereading", async () => {
|
|
64
180
|
const f = fixture();
|
|
65
181
|
const result = await waitForCodexAppServerTurn(f.provider, " thread-1 ", turn("turn-1", "completed", "Approved handover."));
|
|
@@ -2156,3 +2272,88 @@ test("original live observation completion preserves a changed main run and read
|
|
|
2156
2272
|
assert.equal(f.publications.some(event => event.reason === "codex-observation-stopped"), false);
|
|
2157
2273
|
}
|
|
2158
2274
|
});
|
|
2275
|
+
|
|
2276
|
+
for (const [status, cause, explicit, expected] of [
|
|
2277
|
+
["interrupted", "", "", "interrupted"],
|
|
2278
|
+
["interrupted", "control_reconfiguration", "", "control_reconfiguration"],
|
|
2279
|
+
["interrupted", "user_cancelled", "", "user_cancelled"],
|
|
2280
|
+
["failed", "control_reconfiguration", "", "provider_failure"],
|
|
2281
|
+
["interrupted", "control_reconfiguration", "service_restart", "service_restart"]
|
|
2282
|
+
]) {
|
|
2283
|
+
test(`native ${status} preserves ${expected} outcome without inferring its cause`, async () => {
|
|
2284
|
+
const notices = [];
|
|
2285
|
+
const checkpoints = [];
|
|
2286
|
+
const f = await receiptFixture({
|
|
2287
|
+
outcomeNotice(_runtime, sessionId, threadId, turnId, outcome) { notices.push({ sessionId, threadId, turnId, outcome }); },
|
|
2288
|
+
checkpoint(sessionId, value) { checkpoints.push({ sessionId, ...value }); }
|
|
2289
|
+
});
|
|
2290
|
+
const provider = new CodexAppServerAgentProvider({}, { credentials: { assertCurrent() {} } });
|
|
2291
|
+
provider.client = { isOpen: () => true, close() {}, async request(method, input) {
|
|
2292
|
+
assert.equal(method, "turn/interrupt");
|
|
2293
|
+
assert.deepEqual(input, { threadId: f.threadId, turnId: f.turnId });
|
|
2294
|
+
assert.equal(provider.interruptionOutcome(f.threadId, f.turnId), cause, "Cause precedes the actual native request");
|
|
2295
|
+
return {};
|
|
2296
|
+
} };
|
|
2297
|
+
try {
|
|
2298
|
+
if (cause) await provider.interruptTurn(f.threadId, f.turnId, { outcome: cause });
|
|
2299
|
+
f.provider.interruptionOutcome = provider.interruptionOutcome.bind(provider);
|
|
2300
|
+
assert.equal(provider.interruptionOutcome("another-thread", f.turnId), "");
|
|
2301
|
+
assert.equal(provider.interruptionOutcome(f.threadId, "another-turn"), "");
|
|
2302
|
+
const result = await f.owner.stopTurnWithProviderFailure(f.sessionId, f.threadId, f.turnId, {
|
|
2303
|
+
status, provider: f.provider, verifyInactive: false, ...(explicit ? { outcome: explicit } : {})
|
|
2304
|
+
});
|
|
2305
|
+
assert.equal(result.status, status);
|
|
2306
|
+
if (expected === "control_reconfiguration") assert.match(result.error, /while its controls were restored/u);
|
|
2307
|
+
if (status === "interrupted") assert.doesNotMatch(result.error, /provider failed|app-server failed/u);
|
|
2308
|
+
assert.deepEqual(notices, [{ sessionId: f.sessionId, threadId: f.threadId, turnId: f.turnId, outcome: expected }]);
|
|
2309
|
+
assert.deepEqual(checkpoints, [{ sessionId: f.sessionId, status, turnOutcome: expected, threadId: f.threadId, turnId: f.turnId }]);
|
|
2310
|
+
assert.equal(f.run.providerStatus, status);
|
|
2311
|
+
assert.equal(f.run.providerThreadId, f.threadId);
|
|
2312
|
+
assert.equal(f.run.providerTurnId, f.turnId);
|
|
2313
|
+
} finally {
|
|
2314
|
+
provider.close();
|
|
2315
|
+
assert.equal(provider.interruptionOutcome(f.threadId, f.turnId), "");
|
|
2316
|
+
f.owner.clearSessionRecoveryTimers(f.sessionId);
|
|
2317
|
+
}
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
test("user Stop records its exact cause before queued native completion while the interrupt reply is held", async () => {
|
|
2322
|
+
const notices = [];
|
|
2323
|
+
const f = await receiptFixture({ outcomeNotice(_runtime, sessionId, threadId, turnId, outcome) {
|
|
2324
|
+
notices.push({ sessionId, threadId, turnId, outcome });
|
|
2325
|
+
} });
|
|
2326
|
+
const issued = Promise.withResolvers();
|
|
2327
|
+
const release = Promise.withResolvers();
|
|
2328
|
+
const provider = new CodexAppServerAgentProvider({}, { credentials: { assertCurrent() {} } });
|
|
2329
|
+
provider.client = { isOpen: () => true, close() {}, async request(method, input) {
|
|
2330
|
+
assert.equal(method, "turn/interrupt");
|
|
2331
|
+
assert.deepEqual(input, { threadId: f.threadId, turnId: f.turnId });
|
|
2332
|
+
assert.equal(provider.interruptionOutcome(f.threadId, f.turnId), "user_cancelled");
|
|
2333
|
+
f.run.providerStatus = "interrupted";
|
|
2334
|
+
f.emit({ method: "turn/completed", params: { threadId: f.threadId, turn: { id: f.turnId, status: "interrupted" } } });
|
|
2335
|
+
issued.resolve();
|
|
2336
|
+
await release.promise;
|
|
2337
|
+
return {};
|
|
2338
|
+
} };
|
|
2339
|
+
f.provider.interruptTurn = provider.interruptTurn.bind(provider);
|
|
2340
|
+
f.provider.interruptionOutcome = provider.interruptionOutcome.bind(provider);
|
|
2341
|
+
f.observe();
|
|
2342
|
+
const stopped = f.owner.interruptTurn(f.sessionId, {}, controlContext(f));
|
|
2343
|
+
stopped.catch(() => {});
|
|
2344
|
+
try {
|
|
2345
|
+
await issued.promise;
|
|
2346
|
+
await f.drain();
|
|
2347
|
+
assert.deepEqual(notices, [{ sessionId: f.sessionId, threadId: f.threadId, turnId: f.turnId, outcome: "user_cancelled" }],
|
|
2348
|
+
"The queued completion cannot write a provider-failure or unknown-interruption notice first");
|
|
2349
|
+
assert.equal(f.run.state, "interrupted");
|
|
2350
|
+
assert.equal(f.run.providerThreadId, f.threadId);
|
|
2351
|
+
assert.equal(f.run.providerTurnId, f.turnId);
|
|
2352
|
+
} finally {
|
|
2353
|
+
release.resolve();
|
|
2354
|
+
await stopped;
|
|
2355
|
+
provider.close();
|
|
2356
|
+
f.owner.clearSessionRecoveryTimers(f.sessionId);
|
|
2357
|
+
}
|
|
2358
|
+
assert.equal(notices.length, 1);
|
|
2359
|
+
});
|
|
@@ -172,3 +172,30 @@ test("file-backed pending replacement keeps its admitted configuration across a
|
|
|
172
172
|
assert.deepEqual((await resumed.read()).configuration, { systemPrompt: "Replacement instructions.", integrationId: "assistant" });
|
|
173
173
|
assert.equal((await resumed.replace(input)).duplicate, true);
|
|
174
174
|
});
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
test("file-backed OpenCode selections keep compatible bindings and reserve a fresh provider binding without inference", async t => {
|
|
178
|
+
const f = await fixture(t, { file: true });
|
|
179
|
+
const first = f.runtime();
|
|
180
|
+
const conversation = await first.open({ id: "open-code", engine: "opencode",
|
|
181
|
+
configuration: { systemPrompt: "Keep the discussion.", integrationId: "included" } });
|
|
182
|
+
const initial = await conversation.read();
|
|
183
|
+
const old = await f.storage.read("open-code", tx => tx.readMetadata());
|
|
184
|
+
await conversation.select({ operationId: "effort-only", expectedSegmentId: initial.segmentId,
|
|
185
|
+
engine: "opencode", configuration: { ...initial.configuration, effort: "low" } });
|
|
186
|
+
assert.deepEqual((await f.storage.read("open-code", tx => tx.readMetadata())).runtime.binding, old.runtime.binding);
|
|
187
|
+
const selection = { operationId: "connect-flash", expectedSegmentId: initial.segmentId,
|
|
188
|
+
engine: "opencode", configuration: { ...initial.configuration, integrationId: "flash", effort: "low" } };
|
|
189
|
+
const selected = await conversation.select(selection);
|
|
190
|
+
assert.notEqual(selected.segmentId, initial.segmentId);
|
|
191
|
+
const saved = await f.storage.read("open-code", tx => tx.readMetadata());
|
|
192
|
+
assert.notEqual(saved.runtime.binding.directory, old.runtime.binding.directory);
|
|
193
|
+
assert.equal(saved.runtime.binding.sessionId, "");
|
|
194
|
+
assert.deepEqual(saved.runtime.predecessors.at(-1).binding, old.runtime.binding);
|
|
195
|
+
await first.close();
|
|
196
|
+
const reopened = await f.runtime().open({ id: "open-code" });
|
|
197
|
+
assert.equal((await reopened.select(selection)).duplicate, true);
|
|
198
|
+
assert.deepEqual(await f.storage.read("open-code", tx => tx.readMetadata()), saved);
|
|
199
|
+
await assert.rejects(reopened.select({ ...selection, configuration: { ...selection.configuration, integrationId: "different" } }),
|
|
200
|
+
{ code: "conversation_replacement_conflict" });
|
|
201
|
+
});
|