@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
|
@@ -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
|
+
});
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
3
6
|
import { createConversationRuntime, createMemoryConversationStorage } from "../src/server/conversation/index.js";
|
|
4
7
|
import { createAiConnectionResolver } from "../../connectors-catalog/src/server/ai.js";
|
|
5
8
|
|
|
@@ -27,7 +30,7 @@ async function fixture(t, options = {}) {
|
|
|
27
30
|
}) };
|
|
28
31
|
function runtime() {
|
|
29
32
|
const value = createConversationRuntime({ storage: failingStorage, connections,
|
|
30
|
-
authorize: () => state.allowed, limits: options.limits,
|
|
33
|
+
authorize: () => state.allowed, limits: options.limits, host: options.host,
|
|
31
34
|
fetch: async (_url, init) => {
|
|
32
35
|
const body = JSON.parse(init.body); requests.push(body);
|
|
33
36
|
if (options.fetch) return options.fetch(body, init);
|
|
@@ -170,3 +173,75 @@ test("host scope belongs to a conversation and cannot be changed through another
|
|
|
170
173
|
assert.equal((await one.wait()).conversationLog.length, 1);
|
|
171
174
|
assert.equal((await two.wait()).conversationLog.length, 1);
|
|
172
175
|
});
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
test("interrupted OpenCode integration selection retries the original fresh binding transaction without inference", async t => {
|
|
179
|
+
const directory = await mkdtemp(join(tmpdir(), "opencode-selection-replacement-"));
|
|
180
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
181
|
+
const f = await fixture(t, { host: { workdir: directory, execution: {
|
|
182
|
+
start() { assert.fail("Selection must not start a native process or submit a prompt."); },
|
|
183
|
+
stop() { assert.fail("An inert binding has no process to stop."); }
|
|
184
|
+
} } });
|
|
185
|
+
const initial = await f.conversation.read();
|
|
186
|
+
await f.conversation.select({ operationId: "enter-open-code", expectedSegmentId: initial.segmentId,
|
|
187
|
+
engine: "opencode", configuration });
|
|
188
|
+
const original = await f.conversation.read();
|
|
189
|
+
const operation = { operationId: "switch-integration", expectedSegmentId: original.segmentId,
|
|
190
|
+
engine: "opencode", configuration: { ...configuration, integrationId: "flash" } };
|
|
191
|
+
const metadata = await f.storage.read("one", tx => tx.readMetadata());
|
|
192
|
+
f.state.failCommit = true;
|
|
193
|
+
await assert.rejects(f.conversation.select(operation), /Storage offline during replacement commit/);
|
|
194
|
+
const pending = await f.storage.read("one", tx => tx.readMetadata());
|
|
195
|
+
assert.deepEqual(pending.runtime.replacement.request, { ...operation, operation: "select" });
|
|
196
|
+
assert.notEqual(pending.runtime.replacement.binding.directory, metadata.runtime.binding.directory);
|
|
197
|
+
assert.equal((await f.conversation.read()).status, "replacement-pending");
|
|
198
|
+
await assert.rejects(f.conversation.send({ messageId: "premature", text: "Too early" }),
|
|
199
|
+
{ code: "conversation_replacement_pending" });
|
|
200
|
+
await f.first.close();
|
|
201
|
+
f.state.failCommit = false;
|
|
202
|
+
const reopened = await f.runtime().open({ id: "one" });
|
|
203
|
+
const result = await reopened.select(operation);
|
|
204
|
+
assert.equal(result.segmentId, pending.runtime.replacement.segmentId);
|
|
205
|
+
const saved = await f.storage.read("one", tx => tx.readMetadata());
|
|
206
|
+
assert.deepEqual(saved.runtime.binding, pending.runtime.replacement.binding);
|
|
207
|
+
assert.deepEqual(saved.runtime.predecessors.at(-1).binding, metadata.runtime.binding);
|
|
208
|
+
assert.equal(saved.runtime.replacement, undefined);
|
|
209
|
+
assert.equal((await reopened.select(operation)).duplicate, true);
|
|
210
|
+
assert.deepEqual(await f.storage.read("one", tx => tx.readMetadata()), saved);
|
|
211
|
+
assert.equal(f.requests.length, 0);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("interrupted fresh native selection retains its consumer policy and successor on restart", async t => {
|
|
215
|
+
const directory = await mkdtemp(join(tmpdir(), "opencode-fresh-selection-"));
|
|
216
|
+
t.after(() => rm(directory, { recursive: true, force: true }));
|
|
217
|
+
const f = await fixture(t, { host: { workdir: directory, execution: {
|
|
218
|
+
start() { assert.fail("Selection cannot start native work."); },
|
|
219
|
+
stop() { assert.fail("An inert binding has no process to stop."); }
|
|
220
|
+
} } });
|
|
221
|
+
await f.conversation.select({ operationId: "enter-open-code", expectedSegmentId: (await f.conversation.read()).segmentId,
|
|
222
|
+
engine: "opencode", configuration });
|
|
223
|
+
const original = await f.storage.read("one", tx => tx.readMetadata());
|
|
224
|
+
const operation = { operationId: "fresh-model", expectedSegmentId: original.runtime.segmentId,
|
|
225
|
+
engine: "opencode", configuration: { ...configuration, effort: "high" }, retireNative: true };
|
|
226
|
+
f.state.failCommit = true;
|
|
227
|
+
await assert.rejects(f.conversation.select(operation), /Storage offline during replacement commit/);
|
|
228
|
+
const pending = await f.storage.read("one", tx => tx.readMetadata());
|
|
229
|
+
assert.deepEqual(pending.runtime.replacement.request, { ...operation, operation: "select" });
|
|
230
|
+
assert.notEqual(pending.runtime.replacement.binding.directory, original.runtime.binding.directory);
|
|
231
|
+
assert.equal((await f.conversation.read()).status, "replacement-pending");
|
|
232
|
+
await assert.rejects(f.conversation.send({ messageId: "too-early", text: "Wait for the change" }),
|
|
233
|
+
{ code: "conversation_replacement_pending" });
|
|
234
|
+
await f.first.close();
|
|
235
|
+
f.state.failCommit = false;
|
|
236
|
+
const reopened = await f.runtime().open({ id: "one" });
|
|
237
|
+
const result = await reopened.select(operation);
|
|
238
|
+
const saved = await f.storage.read("one", tx => tx.readMetadata());
|
|
239
|
+
assert.equal(result.segmentId, pending.runtime.replacement.segmentId);
|
|
240
|
+
assert.deepEqual(saved.runtime.binding, pending.runtime.replacement.binding);
|
|
241
|
+
assert.deepEqual(saved.runtime.predecessors.at(-1).binding, original.runtime.binding);
|
|
242
|
+
assert.deepEqual(saved.runtime.predecessors.at(-1).replacement, { ...operation, operation: "select" });
|
|
243
|
+
assert.equal(saved.runtime.replacement, undefined);
|
|
244
|
+
assert.equal((await reopened.select(operation)).duplicate, true);
|
|
245
|
+
assert.deepEqual(await f.storage.read("one", tx => tx.readMetadata()), saved);
|
|
246
|
+
assert.equal(f.requests.length, 0);
|
|
247
|
+
});
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
3
6
|
import { createConversationHookBridge } from "../src/server/conversation/index.js";
|
|
4
7
|
import { createClaudeConversationTurn } from "../src/server/conversation/claudeTurn.js";
|
|
5
8
|
import { createCodexConversationAdapter } from "../src/server/conversation/providers/codex.js";
|
|
@@ -74,13 +77,16 @@ test("Claude does not acknowledge failed native settings or launch past failed c
|
|
|
74
77
|
assert.equal(f.launches.length, 2);
|
|
75
78
|
});
|
|
76
79
|
|
|
77
|
-
function codexHost() {
|
|
78
|
-
const state = { status: "idle", process: "process-1", prompt: "Calendar instructions", provider: "openai", fail: false
|
|
80
|
+
function codexHost({ readInstructions } = {}) {
|
|
81
|
+
const state = { status: "idle", process: "process-1", prompt: "Calendar instructions", provider: "openai", fail: false,
|
|
82
|
+
environment: { TOOL_ENDPOINT: "current" } };
|
|
79
83
|
const requests = [];
|
|
80
84
|
const client = { async request(method, input) {
|
|
81
85
|
requests.push({ method, input });
|
|
82
86
|
if (method === "thread/read") return { thread: { historyMode: "paginated", status: state.status, modelProvider: state.provider } };
|
|
83
87
|
if (method === "thread/goal/get") return { goal: null };
|
|
88
|
+
if (method === "thread/turns/list") return { data: [{ id: "active-turn" }] };
|
|
89
|
+
if (method === "turn/interrupt") { state.status = "idle"; return {}; }
|
|
84
90
|
if (method === "thread/inject_items") return {};
|
|
85
91
|
if (method === "thread/loaded/list") return { data: state.status === "notLoaded" ? [] : ["native-history"] };
|
|
86
92
|
if (method === "thread/unsubscribe") { state.status = "notLoaded"; return { status: "unsubscribed" }; }
|
|
@@ -93,11 +99,12 @@ function codexHost() {
|
|
|
93
99
|
} };
|
|
94
100
|
const runtime = createCodexConversationAdapter({ client: async () => client,
|
|
95
101
|
runtime: () => ({ executionId: state.process }), runRequest: (run) => run(),
|
|
96
|
-
readInstructions: () => state.prompt,
|
|
97
|
-
prepareEnvironment: async () => ({
|
|
102
|
+
readInstructions: readInstructions || (() => state.prompt),
|
|
103
|
+
prepareEnvironment: async () => ({ ...state.environment }),
|
|
98
104
|
prepareHistory: async (params) => params,
|
|
99
105
|
threadParameters: (params) => params,
|
|
100
106
|
verifyEnvironment: async () => {},
|
|
107
|
+
recordInterruption: (threadId, turnId) => requests.push({ method: "host.recordInterruption", input: { threadId, turnId } }),
|
|
101
108
|
prepareResume: async (id, params) => { requests.push({ method: "host.prepareResume", input: { id } }); return params; }
|
|
102
109
|
});
|
|
103
110
|
return { runtime, state, requests, client };
|
|
@@ -239,3 +246,165 @@ test("OpenCode retries invalid content and rejects an invalidated in-flight read
|
|
|
239
246
|
release("Obsolete instructions");
|
|
240
247
|
await assert.rejects(inFlight, /context changed while reading/);
|
|
241
248
|
});
|
|
249
|
+
|
|
250
|
+
test("Codex keeps active installed instructions through source changes, deletion and restoration", async t => {
|
|
251
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "codex-instructions-"));
|
|
252
|
+
t.after(() => rm(root, { recursive: true, force: true }));
|
|
253
|
+
const file = path.join(root, "instructions.md");
|
|
254
|
+
const original = "Original project and application instructions";
|
|
255
|
+
await writeFile(file, original);
|
|
256
|
+
let reads = 0;
|
|
257
|
+
const f = codexHost({ readInstructions: () => { reads += 1; return readFile(file, "utf8"); } });
|
|
258
|
+
await f.runtime.withThreadContext("native-history", {}, () => {});
|
|
259
|
+
const installed = f.runtime.threadEnvironments.get("native-history");
|
|
260
|
+
const revision = installed.params.config.shell_environment_policy.set.JSKIT_CONVERSATION_INSTRUCTIONS;
|
|
261
|
+
const initialReads = reads;
|
|
262
|
+
f.requests.length = 0;
|
|
263
|
+
f.state.status = "active";
|
|
264
|
+
let operations = 0;
|
|
265
|
+
for (const content of ["A temporarily changed reference", null, original]) {
|
|
266
|
+
if (content === null) await rm(file);
|
|
267
|
+
else await writeFile(file, content);
|
|
268
|
+
await f.runtime.withThreadContext("native-history", {}, params => {
|
|
269
|
+
operations += 1;
|
|
270
|
+
assert.equal(params.developerInstructions, original);
|
|
271
|
+
assert.equal(params.config.shell_environment_policy.set.JSKIT_CONVERSATION_INSTRUCTIONS, revision);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
assert.equal(operations, 3);
|
|
275
|
+
assert.equal(reads, initialReads, "A healthy active observation must not reread an unavailable prompt source");
|
|
276
|
+
assert.equal(f.runtime.threadEnvironments.get("native-history"), installed);
|
|
277
|
+
assert.equal(f.requests.some(request => ["turn/interrupt", "thread/unsubscribe", "thread/resume", "thread/inject_items"].includes(request.method)), false);
|
|
278
|
+
assert.equal(f.requests.filter(request => request.method === "thread/read").length, 3, "Native status and current controls are still checked");
|
|
279
|
+
await writeFile(file, "Latest restored application instructions");
|
|
280
|
+
f.state.status = "idle";
|
|
281
|
+
await f.runtime.withThreadContext("native-history", {}, params => {
|
|
282
|
+
assert.equal(params.developerInstructions, "Latest restored application instructions");
|
|
283
|
+
});
|
|
284
|
+
assert.equal(reads, initialReads + 1, "The safe admission rereads the source rather than using a queued stale value");
|
|
285
|
+
assert.equal(f.requests.find(request => request.method === "thread/resume").input.threadId, "native-history");
|
|
286
|
+
assert.equal(f.requests.filter(request => request.method === "thread/inject_items").length, 1);
|
|
287
|
+
assert.match(f.requests.find(request => request.method === "thread/inject_items").input.items[0].content[0].text, /Latest restored application instructions/);
|
|
288
|
+
assert.equal(f.runtime.threadEnvironments.get("native-history").client, f.client);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("Codex defers explicit instruction changes while active but installs them at idle admission", async () => {
|
|
292
|
+
const f = codexHost();
|
|
293
|
+
await f.runtime.withThreadContext("native-history", {}, () => {});
|
|
294
|
+
f.requests.length = 0;
|
|
295
|
+
f.state.status = "active";
|
|
296
|
+
const changed = { systemPrompt: "Explicit new application instructions" };
|
|
297
|
+
await f.runtime.withThreadContext("native-history", changed, params => {
|
|
298
|
+
assert.equal(params.developerInstructions, "Calendar instructions");
|
|
299
|
+
});
|
|
300
|
+
assert.equal(f.requests.some(request => request.method === "turn/interrupt" || request.method === "thread/resume"), false);
|
|
301
|
+
f.state.status = "idle";
|
|
302
|
+
await f.runtime.withThreadContext("native-history", changed, params => {
|
|
303
|
+
assert.equal(params.developerInstructions, changed.systemPrompt);
|
|
304
|
+
});
|
|
305
|
+
assert.equal(f.requests.filter(request => request.method === "thread/inject_items").length, 1);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("Codex still recovers real active control changes with current instructions and an exact interruption cause", async () => {
|
|
309
|
+
const f = codexHost();
|
|
310
|
+
await f.runtime.withThreadContext("native-history", {}, () => {});
|
|
311
|
+
f.requests.length = 0;
|
|
312
|
+
f.state.status = "active";
|
|
313
|
+
f.state.prompt = "Current instructions after a real control change";
|
|
314
|
+
f.state.environment.TOOL_ENDPOINT = "replacement";
|
|
315
|
+
await f.runtime.withThreadContext("native-history", {}, params => {
|
|
316
|
+
assert.equal(params.developerInstructions, f.state.prompt);
|
|
317
|
+
assert.equal(params.config.shell_environment_policy.set.TOOL_ENDPOINT, "replacement");
|
|
318
|
+
});
|
|
319
|
+
const interrupt = f.requests.findIndex(request => request.method === "turn/interrupt");
|
|
320
|
+
assert.deepEqual(f.requests[interrupt - 1], { method: "host.recordInterruption", input: { threadId: "native-history", turnId: "active-turn" } });
|
|
321
|
+
assert.deepEqual(f.requests[interrupt].input, { threadId: "native-history", turnId: "active-turn" });
|
|
322
|
+
assert.equal(f.requests.filter(request => request.method === "thread/resume").length, 1);
|
|
323
|
+
assert.equal(f.requests.filter(request => request.method === "thread/inject_items").length, 1);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("Codex does not reinstall a closed binding after an idle instruction read settles", async () => {
|
|
327
|
+
const reading = Promise.withResolvers();
|
|
328
|
+
const instructions = Promise.withResolvers();
|
|
329
|
+
let pending = false;
|
|
330
|
+
const f = codexHost({ readInstructions: () => {
|
|
331
|
+
if (!pending) return "Initial instructions";
|
|
332
|
+
reading.resolve();
|
|
333
|
+
return instructions.promise;
|
|
334
|
+
} });
|
|
335
|
+
await f.runtime.withThreadContext("native-history", {}, () => {});
|
|
336
|
+
pending = true;
|
|
337
|
+
let operations = 0;
|
|
338
|
+
const refresh = f.runtime.withThreadContext("native-history", {}, () => { operations += 1; });
|
|
339
|
+
await reading.promise;
|
|
340
|
+
f.requests.length = 0;
|
|
341
|
+
f.runtime.close();
|
|
342
|
+
instructions.resolve("Late instructions after close");
|
|
343
|
+
await assert.rejects(refresh, /could not be verified/);
|
|
344
|
+
assert.equal(operations, 0);
|
|
345
|
+
assert.equal(f.runtime.bindingCount, 0);
|
|
346
|
+
assert.deepEqual(f.requests, [], "A retired instruction refresh cannot issue native recovery commands");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("Codex defers an instruction-only refresh when native work starts during the prompt read", async () => {
|
|
350
|
+
const reading = Promise.withResolvers();
|
|
351
|
+
const instructions = Promise.withResolvers();
|
|
352
|
+
let pending = false;
|
|
353
|
+
const f = codexHost({ readInstructions: () => {
|
|
354
|
+
if (!pending) return "Installed instructions";
|
|
355
|
+
reading.resolve();
|
|
356
|
+
return instructions.promise;
|
|
357
|
+
} });
|
|
358
|
+
await f.runtime.withThreadContext("native-history", {}, () => {});
|
|
359
|
+
const installed = f.runtime.threadEnvironments.get("native-history");
|
|
360
|
+
f.requests.length = 0;
|
|
361
|
+
pending = true;
|
|
362
|
+
let operations = 0;
|
|
363
|
+
const refresh = f.runtime.withThreadContext("native-history", {}, params => {
|
|
364
|
+
operations += 1;
|
|
365
|
+
assert.equal(params.developerInstructions, installed.params.developerInstructions);
|
|
366
|
+
assert.deepEqual(params.config.shell_environment_policy, installed.params.config.shell_environment_policy);
|
|
367
|
+
});
|
|
368
|
+
await reading.promise;
|
|
369
|
+
f.state.status = "active";
|
|
370
|
+
instructions.resolve("Instructions composed after native work began");
|
|
371
|
+
await refresh;
|
|
372
|
+
assert.equal(operations, 1);
|
|
373
|
+
assert.equal(f.runtime.threadEnvironments.get("native-history"), installed);
|
|
374
|
+
assert.equal(f.requests.filter(request => request.method === "thread/read").length, 2);
|
|
375
|
+
assert.equal(f.requests.some(request => ["thread/goal/get", "turn/interrupt", "thread/unsubscribe", "thread/resume", "thread/inject_items"].includes(request.method)), false);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("Codex never interrupts a late active turn for instructions and restores its paused goal", async () => {
|
|
379
|
+
const f = codexHost();
|
|
380
|
+
await f.runtime.withThreadContext("native-history", {}, () => {});
|
|
381
|
+
const installed = f.runtime.threadEnvironments.get("native-history");
|
|
382
|
+
f.requests.length = 0;
|
|
383
|
+
f.state.prompt = "New instructions for a later admission";
|
|
384
|
+
let goal = { status: "active", createdAt: 1, updatedAt: 1, objective: "Continue the same task" };
|
|
385
|
+
const originalRequest = f.client.request;
|
|
386
|
+
f.client.request = async (method, input) => {
|
|
387
|
+
if (method === "thread/goal/get") {
|
|
388
|
+
f.requests.push({ method, input });
|
|
389
|
+
return { goal: { ...goal } };
|
|
390
|
+
}
|
|
391
|
+
if (method === "thread/goal/set") {
|
|
392
|
+
f.requests.push({ method, input });
|
|
393
|
+
goal = { ...goal, status: input.status, updatedAt: goal.updatedAt + 1 };
|
|
394
|
+
if (input.status === "paused") f.state.status = "active";
|
|
395
|
+
return { goal: { ...goal } };
|
|
396
|
+
}
|
|
397
|
+
return originalRequest(method, input);
|
|
398
|
+
};
|
|
399
|
+
let operations = 0;
|
|
400
|
+
await f.runtime.withThreadContext("native-history", {}, params => {
|
|
401
|
+
operations += 1;
|
|
402
|
+
assert.equal(params.developerInstructions, installed.params.developerInstructions);
|
|
403
|
+
assert.deepEqual(params.config.shell_environment_policy, installed.params.config.shell_environment_policy);
|
|
404
|
+
});
|
|
405
|
+
assert.equal(operations, 1);
|
|
406
|
+
assert.equal(f.runtime.threadEnvironments.get("native-history"), installed);
|
|
407
|
+
assert.deepEqual(f.requests.filter(request => request.method === "thread/goal/set").map(request => request.input.status), ["paused", "active"]);
|
|
408
|
+
assert.equal(goal.status, "active");
|
|
409
|
+
assert.equal(f.requests.some(request => ["host.recordInterruption", "turn/interrupt", "thread/unsubscribe", "thread/resume", "thread/inject_items"].includes(request.method)), false);
|
|
410
|
+
});
|
|
@@ -304,6 +304,145 @@ test("native creation authorizes its parent scope and uses only the configured o
|
|
|
304
304
|
assert.equal(saves.length, 2);
|
|
305
305
|
});
|
|
306
306
|
|
|
307
|
+
test("native detached execution separates admitted context from host-approved original options", async t => {
|
|
308
|
+
const id = "detached-parent";
|
|
309
|
+
const input = { prompt: "Original detached request" };
|
|
310
|
+
const nativeOptions = { runtime: { stateRoot: "/authorized/state" }, session: { sessionId: id }, onEvent: undefined };
|
|
311
|
+
const submittedOptions = { privateProjection: true };
|
|
312
|
+
const result = { ok: true, text: "Completed", threadId: "native-thread", turnId: "native-turn" };
|
|
313
|
+
const calls = [];
|
|
314
|
+
let permitted = false, wrongIdentity = false, preparing = null, entered = null;
|
|
315
|
+
const runtime = createConversationRuntime({
|
|
316
|
+
authorize(request) {
|
|
317
|
+
calls.push("authorize");
|
|
318
|
+
assert.deepEqual(request, { context, conversationId: id, operation: "runDetachedConversation" });
|
|
319
|
+
return permitted;
|
|
320
|
+
},
|
|
321
|
+
storage: { read() { assert.fail("Detached execution does not hydrate Main."); },
|
|
322
|
+
write() { assert.fail("Detached execution does not write canonical conversation history."); } },
|
|
323
|
+
host: { async conversation(request) {
|
|
324
|
+
calls.push("host");
|
|
325
|
+
assert.deepEqual(request, { id, context, input, options: submittedOptions, operation: "runDetachedConversation" });
|
|
326
|
+
entered?.resolve();
|
|
327
|
+
await preparing?.promise;
|
|
328
|
+
return { sessionId: wrongIdentity ? "other-parent" : id, engine: "codex", context, input, options: nativeOptions,
|
|
329
|
+
native: { runOwner: { runDetachedConversation(parent, authored, options) {
|
|
330
|
+
calls.push("native");
|
|
331
|
+
assert.equal(parent, id);
|
|
332
|
+
assert.equal(authored, input);
|
|
333
|
+
assert.equal(options, nativeOptions);
|
|
334
|
+
assert.equal(Object.hasOwn(options, "onEvent"), true);
|
|
335
|
+
assert.equal(Object.hasOwn(options, "signal"), false);
|
|
336
|
+
return result;
|
|
337
|
+
} } } };
|
|
338
|
+
} }
|
|
339
|
+
});
|
|
340
|
+
t.after(() => runtime.close());
|
|
341
|
+
const request = { id, context, input, options: submittedOptions,
|
|
342
|
+
get native() { return assert.fail("The caller cannot inject a native owner."); },
|
|
343
|
+
get engine() { return assert.fail("The caller cannot select another engine."); },
|
|
344
|
+
get host() { return assert.fail("The caller cannot override the configured host."); }
|
|
345
|
+
};
|
|
346
|
+
await assert.rejects(runtime.runNativeDetachedConversation(request), { code: "conversation_forbidden" });
|
|
347
|
+
assert.deepEqual(calls, ["authorize"]);
|
|
348
|
+
permitted = true;
|
|
349
|
+
wrongIdentity = true;
|
|
350
|
+
await assert.rejects(runtime.runNativeDetachedConversation(request), /authorized host preparation/);
|
|
351
|
+
assert.deepEqual(calls, ["authorize", "authorize", "host"]);
|
|
352
|
+
wrongIdentity = false;
|
|
353
|
+
assert.equal(await runtime.runNativeDetachedConversation(request), result);
|
|
354
|
+
assert.deepEqual(calls.slice(3), ["authorize", "host", "native"]);
|
|
355
|
+
preparing = Promise.withResolvers();
|
|
356
|
+
entered = Promise.withResolvers();
|
|
357
|
+
const pending = runtime.runNativeDetachedConversation(request);
|
|
358
|
+
await entered.promise;
|
|
359
|
+
await runtime.close();
|
|
360
|
+
preparing.resolve();
|
|
361
|
+
await assert.rejects(pending, { code: "conversation_closed" });
|
|
362
|
+
assert.deepEqual(calls.slice(6), ["authorize", "host"]);
|
|
363
|
+
await assert.rejects(runtime.runNativeDetachedConversation(request), { code: "conversation_closed" });
|
|
364
|
+
assert.equal(calls.length, 8);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("Claude detached joining preserves acquire-before-audit and awaited event-before-start", async t => {
|
|
368
|
+
for (const rejected of [false, true]) await t.test(rejected ? "event rejection" : "completed turn", async t => {
|
|
369
|
+
const calls = [];
|
|
370
|
+
const eventEntered = Promise.withResolvers();
|
|
371
|
+
const eventRelease = Promise.withResolvers();
|
|
372
|
+
const executionProfile = { model: "selected-helper", limits: { timeoutMs: 500 } };
|
|
373
|
+
const signal = new AbortController().signal;
|
|
374
|
+
const nativeContext = { ...context, assistantScope: { id: "helper-parent" }, signal,
|
|
375
|
+
async onEvent(event) {
|
|
376
|
+
calls.push("profile-event");
|
|
377
|
+
assert.deepEqual(event, { type: "execution-profile", executionProfile });
|
|
378
|
+
eventEntered.resolve();
|
|
379
|
+
await eventRelease.promise;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
const input = { prompt: "Original prompt", executionProfile };
|
|
383
|
+
const entry = { id: "", profile: null };
|
|
384
|
+
const result = { ok: true, text: "Original answer", threadId: "native-id", turnId: "native-turn" };
|
|
385
|
+
const owner = {
|
|
386
|
+
async acquire(actor, id, options) {
|
|
387
|
+
assert.equal(actor, nativeContext);
|
|
388
|
+
if (!entry.id) {
|
|
389
|
+
assert.match(id, /^[a-f0-9-]{36}$/u);
|
|
390
|
+
assert.deepEqual(options, { create: true });
|
|
391
|
+
entry.id = id;
|
|
392
|
+
calls.push("acquire");
|
|
393
|
+
} else {
|
|
394
|
+
assert.equal(id, entry.id);
|
|
395
|
+
calls.push(options ? "start-check" : "wait-acquire");
|
|
396
|
+
if (options) assert.deepEqual(options, { operation: "start", input: { ...input, conversationId: entry.id } });
|
|
397
|
+
}
|
|
398
|
+
return entry;
|
|
399
|
+
},
|
|
400
|
+
async startTurn(current, request) {
|
|
401
|
+
assert.equal(current, entry);
|
|
402
|
+
assert.deepEqual(request, { ...input, conversationId: entry.id });
|
|
403
|
+
assert.equal(entry.profile, executionProfile);
|
|
404
|
+
calls.push("start");
|
|
405
|
+
},
|
|
406
|
+
async wait(current, request, options) {
|
|
407
|
+
assert.equal(current, entry);
|
|
408
|
+
assert.deepEqual(request, { ...input, conversationId: entry.id });
|
|
409
|
+
assert.equal(options.context, nativeContext);
|
|
410
|
+
assert.equal(options.context.signal, signal);
|
|
411
|
+
assert.equal(options.acquire, owner.acquire);
|
|
412
|
+
calls.push("wait");
|
|
413
|
+
return result;
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
const runtime = createConversationRuntime({ authorize: () => true, host: { conversation() {
|
|
417
|
+
return { sessionId: "helper-parent", engine: "claude", context: nativeContext, input, options: {}, native: {
|
|
418
|
+
owner,
|
|
419
|
+
get executionProfile() {
|
|
420
|
+
assert.deepEqual(calls, ["acquire"]);
|
|
421
|
+
calls.push("audit");
|
|
422
|
+
return executionProfile;
|
|
423
|
+
}
|
|
424
|
+
} };
|
|
425
|
+
} } });
|
|
426
|
+
t.after(() => runtime.close());
|
|
427
|
+
const pending = runtime.runNativeDetachedConversation({ id: "helper-parent", context, input });
|
|
428
|
+
void pending.catch(() => {});
|
|
429
|
+
await eventEntered.promise;
|
|
430
|
+
assert.deepEqual(calls, ["acquire", "audit", "profile-event"]);
|
|
431
|
+
assert.equal(entry.profile, executionProfile);
|
|
432
|
+
if (rejected) {
|
|
433
|
+
const failure = new Error("Original observer rejected");
|
|
434
|
+
eventRelease.reject(failure);
|
|
435
|
+
await assert.rejects(pending, error => error === failure);
|
|
436
|
+
assert.deepEqual(calls, ["acquire", "audit", "profile-event"]);
|
|
437
|
+
assert.ok(entry.id, "The original acquired entry is not silently deleted when its event rejects.");
|
|
438
|
+
} else {
|
|
439
|
+
eventRelease.resolve();
|
|
440
|
+
assert.deepEqual(await pending, { ...result, executionProfile });
|
|
441
|
+
assert.deepEqual(calls, ["acquire", "audit", "profile-event", "start-check", "start", "wait-acquire", "wait"]);
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
|
|
307
446
|
test("native readiness authorizes its configured owner and rejects closure during host preparation", async t => {
|
|
308
447
|
const nativeContext = { ...context, key: "readiness-scope", workdir: "/unused-native-workdir" };
|
|
309
448
|
let allowed = false, hostCalls = 0, acquisitions = 0, accounts = 0, saves = 0;
|
|
@@ -181,9 +181,23 @@ test("the runtime applies application tool policy to schemas and an unsolicited
|
|
|
181
181
|
const result = await f.conversation.wait();
|
|
182
182
|
assert.equal((f.observed.requests[0].tools || []).length, 0);
|
|
183
183
|
assert.equal(f.observed.executions.length, 0);
|
|
184
|
-
assert.equal(f.observed.requests.length,
|
|
185
|
-
assert.equal(
|
|
186
|
-
assert.
|
|
184
|
+
assert.equal(f.observed.requests.length, 2);
|
|
185
|
+
assert.equal((f.observed.requests[1].tools || []).length, 0);
|
|
186
|
+
assert.equal(result.conversationLog[0].metadata.runtime.status, "complete");
|
|
187
|
+
assert.equal(result.error, "");
|
|
188
|
+
assert.equal(result.conversationLog[0].assistant.text, "The total is five.");
|
|
189
|
+
const refusal = { ok: false, error: { code: "assistant_tool_unknown", message: "Unknown tool." } };
|
|
190
|
+
const recorded = result.conversationLog[0].metadata.applicationTools[0];
|
|
191
|
+
assert.equal(result.conversationLog[0].metadata.applicationTools.length, 1);
|
|
192
|
+
assert.equal(recorded.id, call.id);
|
|
193
|
+
assert.equal(recorded.name, f.name);
|
|
194
|
+
assert.equal(recorded.arguments, call.arguments);
|
|
195
|
+
assert.equal(recorded.status, "complete");
|
|
196
|
+
assert.deepEqual(recorded.result, refusal);
|
|
197
|
+
const feedback = f.observed.requests[1].messages.filter(message => message.role === "tool");
|
|
198
|
+
assert.equal(feedback.length, 1);
|
|
199
|
+
assert.equal(feedback[0].tool_call_id, call.id);
|
|
200
|
+
assert.deepEqual(JSON.parse(feedback[0].content), { error: refusal.error });
|
|
187
201
|
});
|
|
188
202
|
|
|
189
203
|
test("incomplete model tool streams cannot execute application actions", async t => {
|