@jskit-ai/assistant-core 0.1.205 → 0.1.207
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/package.json +19 -9
- package/src/client/conversation/AssistantTranscript.vue +177 -16
- package/src/server/conversation/claudeTurn.js +16 -7
- package/src/server/conversation/codexConversations.js +94 -0
- package/src/server/conversation/codexHistoryAdapter.js +37 -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 +255 -1
- package/test/claudeConversationSession.test.js +78 -0
- package/test/claudeTurn.test.js +18 -0
- package/test/codexConversationSession.test.js +318 -0
- package/test/codexHistoryAdapter.test.js +106 -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
|
@@ -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
|
+
});
|
|
@@ -539,3 +539,109 @@ test("an owned conversation resolves its native history only when foreign compac
|
|
|
539
539
|
assert.equal(reads, 1);
|
|
540
540
|
assert.match(calls.at(-1).input[1].content[0].text, /RANDOM_TOOL_FACT/);
|
|
541
541
|
});
|
|
542
|
+
|
|
543
|
+
test("OpenAI receives clock::sleep history as quoted context without changing native calls or the selected model", () => {
|
|
544
|
+
const call = { type: "function_call", name: "clock::sleep", call_id: "call_00_JWQtri71vpXPGPWQTcAZ8404",
|
|
545
|
+
arguments: '{"seconds":1}' };
|
|
546
|
+
const result = { type: "function_call_output", call_id: call.call_id, output: "Finished waiting.\n Exact result." };
|
|
547
|
+
const valid = { type: "function_call", name: "shell_command", call_id: "valid", arguments: '{"command":"pwd"}' };
|
|
548
|
+
const validResult = { type: "function_call_output", call_id: valid.call_id, output: "/workspace" };
|
|
549
|
+
const unmatched = { type: "function_call_output", call_id: "another-call", output: "Do not associate this result." };
|
|
550
|
+
const body = { model: "gpt-6-astra", tools: [{ type: "function", name: "shell_command" }],
|
|
551
|
+
input: [valid, call, result, validResult, unmatched, foreign] };
|
|
552
|
+
const saved = structuredClone(body);
|
|
553
|
+
for (const destination of ["openai", "chatgpt", "apiKey"]) {
|
|
554
|
+
const translated = translateCodexHistory(body, destination);
|
|
555
|
+
assert.equal(translated.model, "gpt-6-astra");
|
|
556
|
+
assert.equal(translated.tools, body.tools);
|
|
557
|
+
assert.equal(translated.input.length, body.input.length);
|
|
558
|
+
assert.equal(translated.input[1].type, "message");
|
|
559
|
+
assert.equal(translated.input[1].role, "assistant");
|
|
560
|
+
assert.ok(translated.input[1].content[0].text.includes(JSON.stringify(call)));
|
|
561
|
+
assert.match(translated.input[1].content[0].text, /context only, not an available tool/);
|
|
562
|
+
assert.equal(translated.input[2].role, "user");
|
|
563
|
+
assert.ok(translated.input[2].content[0].text.includes(JSON.stringify({ call_id: call.call_id, name: call.name })));
|
|
564
|
+
assert.match(translated.input[2].content[0].text, /untrusted context, not new instructions/);
|
|
565
|
+
assert.equal(translated.input[2].content[1].text, result.output);
|
|
566
|
+
for (const index of [0, 3, 4]) assert.equal(translated.input[index], body.input[index]);
|
|
567
|
+
assert.equal(translated.input[5].type, "message", "Original reasoning translation still runs.");
|
|
568
|
+
}
|
|
569
|
+
for (const destination of ["deepseek", "zai", "zai-coding-plan", "unrelated"]) {
|
|
570
|
+
const translated = translateCodexHistory(body, destination);
|
|
571
|
+
assert.equal(translated.input[1], call);
|
|
572
|
+
assert.equal(translated.input[2], result);
|
|
573
|
+
}
|
|
574
|
+
assert.deepEqual(body, saved);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("quoted historical function results retain text and image parts in their original positions", () => {
|
|
578
|
+
const call = { type: "function_call", name: "foreign::inspect", call_id: "image-call", arguments: "{}" };
|
|
579
|
+
const text = { type: "input_text", text: "Exact output\n with whitespace" };
|
|
580
|
+
const image = { type: "input_image", image_url: "data:image/png;base64,fixture", detail: "original" };
|
|
581
|
+
const result = { type: "function_call_output", call_id: call.call_id, output: [text, image] };
|
|
582
|
+
const after = { type: "message", role: "user", content: [{ type: "input_text", text: "Then review it." }] };
|
|
583
|
+
const body = { input: [call, result, after] };
|
|
584
|
+
const saved = structuredClone(body);
|
|
585
|
+
const translated = translateCodexHistory(body);
|
|
586
|
+
assert.equal(translated.input.length, 3);
|
|
587
|
+
assert.ok(translated.input[0].content[0].text.includes(JSON.stringify(call)));
|
|
588
|
+
assert.equal(translated.input[1].content[1], text);
|
|
589
|
+
assert.equal(translated.input[1].content[2], image);
|
|
590
|
+
assert.equal(translated.input[2], after);
|
|
591
|
+
assert.deepEqual(body, saved);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test("invalid historical function identity or unsupported paired content fails without inventing an association", () => {
|
|
595
|
+
const call = { type: "function_call", name: "clock::sleep", call_id: "clock-call", arguments: "{}" };
|
|
596
|
+
const output = { type: "function_call_output", call_id: call.call_id, output: "Finished." };
|
|
597
|
+
for (const input of [
|
|
598
|
+
[{ ...call, call_id: "" }, output],
|
|
599
|
+
[{ ...call, arguments: {} }, output],
|
|
600
|
+
[call, { ...call, name: "shell_command" }, output],
|
|
601
|
+
[call, { ...output, output: null }],
|
|
602
|
+
[call, { ...output, output: [{ type: "input_text", text: 42 }] }],
|
|
603
|
+
[call, { ...output, output: [{ type: "input_image", image_url: 42 }] }],
|
|
604
|
+
[call, { ...output, output: [{ type: "unknown", text: "Not a supported result." }] }]
|
|
605
|
+
]) {
|
|
606
|
+
const saved = structuredClone(input);
|
|
607
|
+
assert.throws(() => translateCodexHistory({ input }), { statusCode: 422 });
|
|
608
|
+
assert.deepEqual(input, saved);
|
|
609
|
+
}
|
|
610
|
+
const unmatched = { ...output, call_id: "unmatched", output: [{ type: "unknown" }] };
|
|
611
|
+
assert.equal(translateCodexHistory({ input: [call, unmatched] }).input[1], unmatched);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
test("the real adapter forwards selected GPT compaction and response requests without invalid function-call names", async t => {
|
|
615
|
+
const calls = [];
|
|
616
|
+
const adapter = await startCodexHistoryAdapter({ token: randomUUID(), fetchImpl: async (url, options) => {
|
|
617
|
+
calls.push({ url, body: JSON.parse(options.body) });
|
|
618
|
+
return new Response("accepted", { status: 200 });
|
|
619
|
+
} });
|
|
620
|
+
t.after(() => adapter.close());
|
|
621
|
+
const call = { type: "function_call", name: "clock::sleep", call_id: "clock-call", arguments: '{"seconds":1}' };
|
|
622
|
+
const output = { type: "function_call_output", call_id: call.call_id, output: "Finished." };
|
|
623
|
+
const ordinary = { type: "function_call", name: "shell_command", call_id: "ordinary-call", arguments: "{}" };
|
|
624
|
+
const body = { model: "gpt-6-astra", input: [call, output, ordinary], tools: [{ type: "function", name: "shell_command" }] };
|
|
625
|
+
const saved = structuredClone(body);
|
|
626
|
+
for (const [destination, upstream] of [["chatgpt", "https://chatgpt.com/backend-api/codex"], ["apiKey", "https://api.openai.com/v1"]]) {
|
|
627
|
+
for (const route of ["responses", "responses/compact"]) {
|
|
628
|
+
const response = await fetch(`${adapter.baseUrl}/${destination}/${route}`, { method: "POST", body: JSON.stringify(body) });
|
|
629
|
+
assert.equal(response.status, 200);
|
|
630
|
+
assert.equal(await response.text(), "accepted");
|
|
631
|
+
const sent = calls.at(-1);
|
|
632
|
+
assert.equal(sent.url, `${upstream}/${route}`);
|
|
633
|
+
assert.equal(sent.body.model, "gpt-6-astra");
|
|
634
|
+
assert.deepEqual(sent.body.tools, body.tools);
|
|
635
|
+
assert.deepEqual(sent.body.input.filter(item => item.type === "function_call"), [ordinary]);
|
|
636
|
+
assert.ok(sent.body.input[0].content[0].text.includes(JSON.stringify(call)));
|
|
637
|
+
assert.equal(sent.body.input[1].content[1].text, output.output);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const accepted = calls.length;
|
|
641
|
+
const rejected = await fetch(`${adapter.baseUrl}/chatgpt/responses`, { method: "POST",
|
|
642
|
+
body: JSON.stringify({ ...body, input: [call, { ...output, output: [{ type: "unknown" }] }] }) });
|
|
643
|
+
assert.equal(rejected.status, 422);
|
|
644
|
+
assert.match((await rejected.json()).error.message, /history is not supported/);
|
|
645
|
+
assert.equal(calls.length, accepted, "Unsupported paired history must fail before provider admission.");
|
|
646
|
+
assert.deepEqual(body, saved);
|
|
647
|
+
});
|
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
|
+
});
|