@jskit-ai/assistant-core 0.1.223 → 0.1.225
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 +1 -1
- package/fixtures/responsive-assistant/DeliveryFixture.vue +3 -2
- package/package.json +1 -1
- package/src/client/conversation/AssistantGoalControl.vue +8 -8
- package/src/client/conversation/AssistantTranscript.vue +6 -4
- package/src/server/conversation/agentRun.js +12 -0
- package/src/server/conversation/claudeHistory.js +9 -3
- package/src/server/conversation/claudeProcess.js +60 -20
- package/src/server/conversation/claudeTurn.js +190 -41
- package/src/server/conversation/codexProvider.js +67 -3
- package/src/server/conversation/index.js +2 -2
- package/src/server/conversation/openCodeRuntime.js +8 -4
- package/src/server/conversation/providers/claude.js +5 -1
- package/src/server/conversation/providers/claudeDriver.js +5 -1
- package/src/server/conversation/providers/codex.js +325 -216
- package/src/server/conversation/providers/codexDriver.js +14 -1
- package/src/server/conversation/providers/scoped.js +39 -23
- package/src/server/conversation/runtime.js +20 -1
- package/src/server/conversation/runtimeStateUpgrade.js +42 -0
- package/test/claudeConversationSession.test.js +3 -2
- package/test/claudeProcess.test.js +139 -1
- package/test/claudeTurn.test.js +253 -3
- package/test/codexConversationSession.test.js +27 -4
- package/test/codexGoal.test.js +152 -0
- package/test/conversationRuntime.test.js +168 -4
- package/test/conversationRuntimeStateUpgrade.test.js +69 -1
- package/test/conversationSession.test.js +48 -0
- package/test/messageDelivery.browser.test.js +15 -0
- package/test/openCodeRuntime.test.js +71 -1
|
@@ -3,7 +3,7 @@ import test from "node:test";
|
|
|
3
3
|
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { createConversationHookBridge } from "../src/server/conversation/index.js";
|
|
6
|
+
import { createConversationHookBridge, createConversationRuntime } from "../src/server/conversation/index.js";
|
|
7
7
|
import { createClaudeConversationTurn } from "../src/server/conversation/claudeTurn.js";
|
|
8
8
|
import { createCodexConversationAdapter } from "../src/server/conversation/providers/codex.js";
|
|
9
9
|
import { createOpenCodeConversationAdapter } from "../src/server/conversation/providers/opencode.js";
|
|
@@ -29,6 +29,36 @@ function claudeHost() {
|
|
|
29
29
|
const claudeInput = { systemPrompt: "Use the calendar tools.", contextIdentity: "calendar/model-one",
|
|
30
30
|
model: "one", settings: { effortLevel: "high" }, liveUpdateIdentity: "calendar-schema" };
|
|
31
31
|
|
|
32
|
+
test("saved persistent history projects receipts and pages without native observation or writes", async () => {
|
|
33
|
+
const runtime = createConversationRuntime({ authorize: async () => true, host: { conversation: () => ({}) } });
|
|
34
|
+
const turn = { turnId: "turn-1", user: { messageId: "user-1", attachments: [] },
|
|
35
|
+
messages: [{ role: "user", messageId: "user-1", text: "Saved draft", at: "2026-01-01T00:00:00Z" }] };
|
|
36
|
+
const calls = [];
|
|
37
|
+
const options = {
|
|
38
|
+
sessionId: "session-1", record: { conversationId: "chat-1", status: "starting", runId: "run-1", messageId: "user-1" },
|
|
39
|
+
context: { actor: "owner" }, includeAccess: true, transcriptQuery: { limit: 10 },
|
|
40
|
+
store: {
|
|
41
|
+
async readConversationLog(scope) { calls.push(["log", scope]); return [turn]; },
|
|
42
|
+
async readConversationLogPage(scope, query) { calls.push(["page", scope, query]); return { conversationLog: [turn], pagination: { more: false } }; }
|
|
43
|
+
},
|
|
44
|
+
operations: new Proxy({}, { get() { throw new Error("native operation must not run"); } }),
|
|
45
|
+
receipts: { persisted: () => false },
|
|
46
|
+
projection: {
|
|
47
|
+
context: (context) => context,
|
|
48
|
+
messageMetadata: () => ({}),
|
|
49
|
+
async snapshot(input) { return { ...input, page: await input.readPage() }; }
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const result = await runtime.projectStoredPersistentConversation(options);
|
|
53
|
+
assert.equal(result.includeAccess, true);
|
|
54
|
+
assert.equal(result.response.status, "starting");
|
|
55
|
+
assert.equal(result.messages[0].receipt, false);
|
|
56
|
+
assert.equal(result.page.conversationLog[0].user.receipt, false);
|
|
57
|
+
assert.deepEqual(calls.map(([kind]) => kind), ["log", "page"]);
|
|
58
|
+
await runtime.close();
|
|
59
|
+
await assert.rejects(runtime.projectStoredPersistentConversation(options), { code: "conversation_closed" });
|
|
60
|
+
});
|
|
61
|
+
|
|
32
62
|
test("a standalone Claude consumer reuses, updates and replaces processes without changing native history", async () => {
|
|
33
63
|
const f = claudeHost();
|
|
34
64
|
const input = { ...claudeInput, nativeId: "retained-history" };
|
|
@@ -57,6 +87,21 @@ test("Claude rejects active prompt changes without invalidating ordinary steerin
|
|
|
57
87
|
assert.equal(f.launches.length, 1);
|
|
58
88
|
});
|
|
59
89
|
|
|
90
|
+
test("Claude applies model and effort changes during work without restarting its process", async () => {
|
|
91
|
+
const f = claudeHost();
|
|
92
|
+
await f.runtime.prepare(claudeInput);
|
|
93
|
+
f.state.active = true;
|
|
94
|
+
await f.runtime.prepare({ ...claudeInput, contextIdentity: "calendar/model-two", model: "two",
|
|
95
|
+
settings: { effortLevel: "low" } });
|
|
96
|
+
assert.equal(f.state.active, true);
|
|
97
|
+
assert.equal(f.launches.length, 1);
|
|
98
|
+
assert.deepEqual(f.requests.slice(-2), [
|
|
99
|
+
{ subtype: "apply_flag_settings", settings: { effortLevel: "low" } },
|
|
100
|
+
{ subtype: "set_model", model: "two" }
|
|
101
|
+
]);
|
|
102
|
+
await assert.rejects(f.runtime.prepare({ ...claudeInput, liveUpdateIdentity: "changed-tools" }), /Stop the current/);
|
|
103
|
+
});
|
|
104
|
+
|
|
60
105
|
test("Claude restarts when only the native instruction mode changes", async () => {
|
|
61
106
|
const f = claudeHost();
|
|
62
107
|
await f.runtime.prepare(claudeInput);
|
|
@@ -81,10 +126,14 @@ function codexHost({ readInstructions } = {}) {
|
|
|
81
126
|
const state = { status: "idle", process: "process-1", prompt: "Calendar instructions", provider: "openai", fail: false,
|
|
82
127
|
environment: { TOOL_ENDPOINT: "current" } };
|
|
83
128
|
const requests = [];
|
|
84
|
-
const client = { async request(method, input) {
|
|
129
|
+
const client = { isOpen: () => true, async request(method, input) {
|
|
85
130
|
requests.push({ method, input });
|
|
86
131
|
if (method === "thread/read") return { thread: { historyMode: "paginated", status: state.status, modelProvider: state.provider } };
|
|
87
|
-
if (method === "thread/goal/get") return { goal: null };
|
|
132
|
+
if (method === "thread/goal/get") return { goal: state.goal ? { ...state.goal } : null };
|
|
133
|
+
if (method === "thread/goal/set") {
|
|
134
|
+
state.goal = { ...state.goal, status: input.status, updatedAt: state.goal.updatedAt + 1 };
|
|
135
|
+
return { goal: { ...state.goal } };
|
|
136
|
+
}
|
|
88
137
|
if (method === "thread/turns/list") return { data: [{ id: "active-turn" }] };
|
|
89
138
|
if (method === "turn/interrupt") { state.status = "idle"; return {}; }
|
|
90
139
|
if (method === "thread/inject_items") return {};
|
|
@@ -93,7 +142,8 @@ function codexHost({ readInstructions } = {}) {
|
|
|
93
142
|
if (method === "thread/resume") {
|
|
94
143
|
if (state.fail) throw new Error("resume failed");
|
|
95
144
|
state.status = "idle";
|
|
96
|
-
|
|
145
|
+
state.provider = input.modelProvider || state.provider;
|
|
146
|
+
return { modelProvider: state.provider };
|
|
97
147
|
}
|
|
98
148
|
throw new Error(`Unexpected native request: ${method}`);
|
|
99
149
|
} };
|
|
@@ -104,6 +154,7 @@ function codexHost({ readInstructions } = {}) {
|
|
|
104
154
|
prepareHistory: async (params) => params,
|
|
105
155
|
threadParameters: (params) => params,
|
|
106
156
|
verifyEnvironment: async () => {},
|
|
157
|
+
publishGoal: (threadId, goal) => requests.push({ method: "host.goal", input: { threadId, goal } }),
|
|
107
158
|
recordInterruption: (threadId, turnId) => requests.push({ method: "host.recordInterruption", input: { threadId, turnId } }),
|
|
108
159
|
prepareResume: async (id, params) => { requests.push({ method: "host.prepareResume", input: { id } }); return params; }
|
|
109
160
|
});
|
|
@@ -503,3 +554,116 @@ test("Codex excludes deferred instruction composition without replenishing spent
|
|
|
503
554
|
});
|
|
504
555
|
}
|
|
505
556
|
});
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
for (const action of ["continue", "pause", "cancel", "complete", "close", "invalidate", "failure"]) {
|
|
560
|
+
test(`next-turn Codex provider settings preserve the current turn and ${action}`, async () => {
|
|
561
|
+
const f = codexHost();
|
|
562
|
+
const id = "native-history";
|
|
563
|
+
await f.runtime.withThreadContext(id, { modelProvider: "openai" }, () => {});
|
|
564
|
+
f.requests.length = 0;
|
|
565
|
+
f.state.status = "active";
|
|
566
|
+
f.state.goal = { status: "active", createdAt: 1, updatedAt: 1, objective: "Finish the same goal", tokenBudget: 100, tokensUsed: 40 };
|
|
567
|
+
const applied = [];
|
|
568
|
+
const update = params => { applied.push(params.model); return { ok: true }; };
|
|
569
|
+
const first = { modelProvider: "deepseek", model: "first", config: { "model_providers.deepseek": { base_url: "https://example.test" } } };
|
|
570
|
+
assert.deepEqual(await f.runtime.scheduleSettings(id, first, update), { ok: true, pending: true });
|
|
571
|
+
const task = f.runtime.threadEnvironmentTasks.get(id);
|
|
572
|
+
assert.equal(f.state.goal.status, "paused");
|
|
573
|
+
assert.equal(f.runtime.projectGoal(id, f.state.goal).status, "active", "An internal scheduling hold is not a user Pause");
|
|
574
|
+
assert.equal(f.requests.some(r => ["turn/interrupt", "thread/resume", "thread/unsubscribe"].includes(r.method)), false);
|
|
575
|
+
assert.deepEqual(await f.runtime.scheduleSettings(id, { ...first, model: "latest" }, update), { ok: true, pending: true });
|
|
576
|
+
if (["pause", "cancel"].includes(action)) f.runtime.goalChanged(id);
|
|
577
|
+
if (action === "pause") f.state.goal = { ...f.state.goal, status: "paused", updatedAt: 3 };
|
|
578
|
+
if (action === "cancel") f.state.goal = null;
|
|
579
|
+
if (action === "complete") f.state.goal = { ...f.state.goal, status: "complete", updatedAt: 3 };
|
|
580
|
+
if (action === "failure") f.state.fail = true;
|
|
581
|
+
if (action === "close") f.runtime.close();
|
|
582
|
+
if (action === "invalidate") f.runtime.invalidateThread(id);
|
|
583
|
+
f.state.status = "idle";
|
|
584
|
+
if (["close", "invalidate", "failure"].includes(action)) {
|
|
585
|
+
await assert.rejects(task);
|
|
586
|
+
assert.deepEqual(applied, []);
|
|
587
|
+
assert.equal(f.runtime.projectGoal(id, f.state.goal).status, "paused");
|
|
588
|
+
} else {
|
|
589
|
+
await task;
|
|
590
|
+
assert.deepEqual(applied, ["latest"]);
|
|
591
|
+
assert.equal(f.state.provider, "deepseek");
|
|
592
|
+
assert.equal(f.state.goal?.status || null, { continue: "active", pause: "paused", cancel: null, complete: "complete" }[action]);
|
|
593
|
+
if (f.state.goal) for (const [key, value] of Object.entries({ createdAt: 1, objective: "Finish the same goal", tokenBudget: 100, tokensUsed: 40 })) {
|
|
594
|
+
assert.equal(f.state.goal[key], value);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
assert.equal(f.requests.some(r => r.method === "turn/interrupt"), false);
|
|
598
|
+
f.runtime.close();
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
test("a settings save during Codex reload replaces the older choice before its goal resumes", async () => {
|
|
603
|
+
const f = codexHost();
|
|
604
|
+
const id = "native-history";
|
|
605
|
+
await f.runtime.withThreadContext(id, { modelProvider: "openai" }, () => {});
|
|
606
|
+
f.state.goal = { status: "active", createdAt: 1, updatedAt: 1, objective: "Continue" };
|
|
607
|
+
const params = { modelProvider: "deepseek", model: "first" };
|
|
608
|
+
const applied = [];
|
|
609
|
+
await f.runtime.scheduleSettings(id, params, async selected => {
|
|
610
|
+
applied.push(selected.model);
|
|
611
|
+
if (selected.model === "first") {
|
|
612
|
+
void f.runtime.scheduleSettings(id, { ...params, model: "latest" }, () => assert.fail("The retained owner handles later saves"));
|
|
613
|
+
}
|
|
614
|
+
assert.equal(f.state.goal.status, "paused");
|
|
615
|
+
});
|
|
616
|
+
assert.deepEqual(applied, ["first", "latest"]);
|
|
617
|
+
assert.equal(f.state.goal.status, "active");
|
|
618
|
+
assert.equal(f.requests.filter(r => r.method === "thread/goal/set" && r.input.status === "active").length, 1);
|
|
619
|
+
f.runtime.close();
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("Codex retains settings saved during goal resume acknowledgement and after completion", async () => {
|
|
623
|
+
const f = codexHost();
|
|
624
|
+
const id = "native-history";
|
|
625
|
+
await f.runtime.withThreadContext(id, { modelProvider: "openai" }, () => {});
|
|
626
|
+
f.state.goal = { status: "active", createdAt: 1, updatedAt: 1, objective: "Continue" };
|
|
627
|
+
const params = { modelProvider: "deepseek", model: "first" };
|
|
628
|
+
const applied = [];
|
|
629
|
+
const update = selected => { applied.push(selected.model); };
|
|
630
|
+
const request = f.client.request.bind(f.client);
|
|
631
|
+
let savedDuringResume = false;
|
|
632
|
+
f.client.request = async (method, input) => {
|
|
633
|
+
const result = await request(method, input);
|
|
634
|
+
if (method === "thread/goal/set" && input.status === "active" && !savedDuringResume) {
|
|
635
|
+
savedDuringResume = true;
|
|
636
|
+
void f.runtime.scheduleSettings(id, { ...params, model: "during-resume" }, update);
|
|
637
|
+
}
|
|
638
|
+
return result;
|
|
639
|
+
};
|
|
640
|
+
await f.runtime.scheduleSettings(id, params, update);
|
|
641
|
+
assert.deepEqual(applied, ["first", "during-resume"]);
|
|
642
|
+
assert.equal(f.runtime.hasPendingSettings(id), false);
|
|
643
|
+
await f.runtime.scheduleSettings(id, { ...params, model: "after-completion" }, update);
|
|
644
|
+
assert.deepEqual(applied, ["first", "during-resume", "after-completion"]);
|
|
645
|
+
assert.equal(f.state.goal.status, "active");
|
|
646
|
+
assert.equal(f.requests.some(r => r.method === "turn/interrupt"), false);
|
|
647
|
+
f.runtime.close();
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test("a user Pause during the final goal read prevents automatic settings recovery from resuming it", async () => {
|
|
651
|
+
const f = codexHost();
|
|
652
|
+
const id = "native-history";
|
|
653
|
+
await f.runtime.withThreadContext(id, { modelProvider: "openai" }, () => {});
|
|
654
|
+
f.state.goal = { status: "active", createdAt: 1, updatedAt: 1, objective: "Continue" };
|
|
655
|
+
const request = f.client.request.bind(f.client);
|
|
656
|
+
let settingsApplied = false;
|
|
657
|
+
f.client.request = async (method, input) => {
|
|
658
|
+
const result = await request(method, input);
|
|
659
|
+
if (method === "thread/goal/get" && settingsApplied) {
|
|
660
|
+
f.runtime.goalChanged(id);
|
|
661
|
+
f.state.goal = { ...f.state.goal, status: "paused", updatedAt: f.state.goal.updatedAt + 1 };
|
|
662
|
+
}
|
|
663
|
+
return result;
|
|
664
|
+
};
|
|
665
|
+
await f.runtime.scheduleSettings(id, { modelProvider: "deepseek", model: "next" }, () => { settingsApplied = true; });
|
|
666
|
+
assert.equal(f.state.goal.status, "paused");
|
|
667
|
+
assert.equal(f.requests.some(r => r.method === "thread/goal/set" && r.input.status === "active"), false);
|
|
668
|
+
f.runtime.close();
|
|
669
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
-
import { upgradeConversationRuntimeState } from "../src/server/conversation/runtimeStateUpgrade.js";
|
|
3
|
+
import { upgradeConversationRuntimeState, prepareConversationRuntimeCopy } from "../src/server/conversation/runtimeStateUpgrade.js";
|
|
4
|
+
import { createMemoryConversationStorage } from "../src/server/conversation/memoryStorage.js";
|
|
5
|
+
import { codexAppServerTurnStateFromAgentRun } from "../src/server/conversation/codexTurnState.js";
|
|
4
6
|
|
|
5
7
|
const configuration = { systemPrompt: "Retained instructions", model: "saved-model" };
|
|
6
8
|
const request = { messageId: "uncertain", text: "Authored text", origin: "user", attachments: [{ attachmentId: "file" }],
|
|
@@ -19,6 +21,72 @@ const record = (engine = "claude", extra = {}) => ({
|
|
|
19
21
|
messages: [{ role: "user", text: "Accepted before model selection", messageId: "old-message" }] }]
|
|
20
22
|
});
|
|
21
23
|
|
|
24
|
+
test("a stopped development copy preserves native identity, history and uncertain input without retaining active execution", async () => {
|
|
25
|
+
const at = "2026-10-15T00:00:00.000Z";
|
|
26
|
+
for (const engine of ["api", "codex", "claude", "opencode"]) {
|
|
27
|
+
const value = record(engine, { version: 3, lastEngine: engine,
|
|
28
|
+
...(engine === "api" ? {} : { request: { ...request, inspectionOnly: true,
|
|
29
|
+
threadId: engine === "codex" ? "saved-thread" : engine === "claude" ? "saved-conversation" : "saved-session" } }),
|
|
30
|
+
nativeTurn: { active: true, state: "active", phase: "working", goalStatus: "active" } });
|
|
31
|
+
if (value.metadata.runtime.binding) Object.assign(value.metadata.runtime.binding = { ...value.metadata.runtime.binding }, {
|
|
32
|
+
executionId: "parent-process", processDirectory: "/parent/process" });
|
|
33
|
+
if (engine === "codex") value.metadata.runtime.binding.codexAppServerRun = {
|
|
34
|
+
id: "codex_app_server", state: "active", providerThreadId: "saved-thread", providerTurnId: "native-turn",
|
|
35
|
+
providerStatus: "inProgress", providerGoalStatus: "active", pendingUserMessageClientIds: ["pending"], events: []
|
|
36
|
+
};
|
|
37
|
+
const before = structuredClone(value);
|
|
38
|
+
const storage = createMemoryConversationStorage();
|
|
39
|
+
await storage.write("copy", async transaction => {
|
|
40
|
+
await transaction.writeMetadata(value.metadata);
|
|
41
|
+
await transaction.appendMessage("turn", { role: "user", text: "Existing question", messageId: "authored", at,
|
|
42
|
+
turnMetadata: { runtime: { engine, segmentId: `${engine}-segment`, status: "running" } } });
|
|
43
|
+
});
|
|
44
|
+
await storage.write("copy", transaction => prepareConversationRuntimeCopy(transaction, { at }));
|
|
45
|
+
const copied = await storage.read("copy", async transaction => ({
|
|
46
|
+
metadata: await transaction.readMetadata(), turn: await transaction.readTurn("turn")
|
|
47
|
+
}));
|
|
48
|
+
const saved = copied.metadata.runtime;
|
|
49
|
+
assert.deepEqual(value, before, "The parent input is never mutated");
|
|
50
|
+
assert.equal(saved.segmentId, before.metadata.runtime.segmentId);
|
|
51
|
+
assert.deepEqual(saved.configuration, configuration);
|
|
52
|
+
assert.equal(saved.nativeTurn.active, false);
|
|
53
|
+
assert.equal(saved.nativeTurn.goalStatus, "paused");
|
|
54
|
+
assert.equal(copied.turn.metadata.runtime.status, "interrupted");
|
|
55
|
+
assert.equal(copied.turn.messages[0].text, "Existing question");
|
|
56
|
+
if (engine !== "api") {
|
|
57
|
+
assert.equal(saved.binding.executionId, "");
|
|
58
|
+
assert.equal(saved.binding.processDirectory, "");
|
|
59
|
+
assert.equal(saved.request.text, request.text);
|
|
60
|
+
assert.equal(saved.request.inspectionOnly, true);
|
|
61
|
+
assert.deepEqual(saved.request.attachments, request.attachments);
|
|
62
|
+
assert.match(saved.request.error, /not replayed/);
|
|
63
|
+
}
|
|
64
|
+
if (engine === "codex") {
|
|
65
|
+
assert.equal(saved.binding.threadId, "saved-thread");
|
|
66
|
+
const turn = codexAppServerTurnStateFromAgentRun(saved.binding.codexAppServerRun);
|
|
67
|
+
assert.equal(turn.active, false);
|
|
68
|
+
assert.equal(turn.goalStatus, "paused");
|
|
69
|
+
assert.equal(turn.turnId, "native-turn");
|
|
70
|
+
assert.deepEqual(saved.binding.codexAppServerRun.pendingUserMessageClientIds, []);
|
|
71
|
+
}
|
|
72
|
+
await storage.write("copy", transaction => prepareConversationRuntimeCopy(transaction, { at }));
|
|
73
|
+
assert.deepEqual(await storage.read("copy", transaction => transaction.readMetadata()), copied.metadata);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("development copy rejects incomplete changeover and older runtime before writing", async () => {
|
|
78
|
+
for (const metadata of [record().metadata, record("claude", {
|
|
79
|
+
version: 3, lastEngine: "claude", replacement: { ...segment("claude"), request: { operationId: "pending" } }
|
|
80
|
+
}).metadata]) {
|
|
81
|
+
let wrote = false;
|
|
82
|
+
await assert.rejects(prepareConversationRuntimeCopy({
|
|
83
|
+
readMetadata: async () => metadata, listTurnIds: async () => [],
|
|
84
|
+
writeMetadata: async () => { wrote = true; }
|
|
85
|
+
}, { at: "2026-10-15T00:00:00Z" }), /Upgrade the parent|Finish the parent/);
|
|
86
|
+
assert.equal(wrote, false);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
22
90
|
test("offline conversion preserves exact native bindings and authored requests without inventing a prompt", () => {
|
|
23
91
|
const value = record("claude", { predecessors: [segment("codex", { request: structuredClone(request) })] });
|
|
24
92
|
const before = structuredClone(value);
|
|
@@ -1236,3 +1236,51 @@ for (const text of ["abc", "abcd"]) {
|
|
|
1236
1236
|
if (text.length > 3) assert.match(result.error, /final reply limit/);
|
|
1237
1237
|
});
|
|
1238
1238
|
}
|
|
1239
|
+
|
|
1240
|
+
test("native settings authorize their scope and reject replaced or closed host preparation", async t => {
|
|
1241
|
+
const context = { actor: { id: "owner" } };
|
|
1242
|
+
const nativeContext = { sessionId: "settings-scope", authorized: true };
|
|
1243
|
+
const input = { threadId: "owned-thread", model: "next-model", effort: "high" };
|
|
1244
|
+
let allowed = false, hostCalls = 0, acquisitions = 0, updates = 0;
|
|
1245
|
+
let scope = "settings-scope", gate;
|
|
1246
|
+
const owner = {
|
|
1247
|
+
async acquire(prepared, threadId) {
|
|
1248
|
+
assert.equal(prepared, nativeContext);
|
|
1249
|
+
assert.equal(threadId, input.threadId);
|
|
1250
|
+
acquisitions += 1;
|
|
1251
|
+
return { process: {} };
|
|
1252
|
+
},
|
|
1253
|
+
async ensureProcess() { updates += 1; }
|
|
1254
|
+
};
|
|
1255
|
+
const runtime = createConversationRuntime({
|
|
1256
|
+
authorize: request => allowed && request.context === context &&
|
|
1257
|
+
request.conversationId === "settings-scope" && request.operation === "configure",
|
|
1258
|
+
host: { async conversation(request) {
|
|
1259
|
+
hostCalls += 1;
|
|
1260
|
+
assert.deepEqual(request, { id: "settings-scope", context, input, operation: "updateSettings" });
|
|
1261
|
+
if (gate) { gate.entered.resolve(); await gate.finished.promise; }
|
|
1262
|
+
return { sessionId: scope, engine: "claude", native: { owner }, context: nativeContext };
|
|
1263
|
+
} }
|
|
1264
|
+
});
|
|
1265
|
+
t.after(() => runtime.close());
|
|
1266
|
+
const request = { id: "settings-scope", context, input,
|
|
1267
|
+
get native() { return assert.fail("The caller cannot select a native owner"); },
|
|
1268
|
+
get engine() { return assert.fail("The caller cannot select an engine"); } };
|
|
1269
|
+
await assert.rejects(runtime.updateNativeConversationSettings(request), { code: "conversation_forbidden" });
|
|
1270
|
+
assert.deepEqual([hostCalls, acquisitions, updates], [0, 0, 0]);
|
|
1271
|
+
allowed = true;
|
|
1272
|
+
scope = "another-scope";
|
|
1273
|
+
await assert.rejects(runtime.updateNativeConversationSettings(request), /authorized host preparation/);
|
|
1274
|
+
assert.deepEqual([acquisitions, updates], [0, 0]);
|
|
1275
|
+
scope = "settings-scope";
|
|
1276
|
+
assert.deepEqual(await runtime.updateNativeConversationSettings(request), { ok: true });
|
|
1277
|
+
assert.deepEqual([acquisitions, updates], [1, 1]);
|
|
1278
|
+
gate = { entered: Promise.withResolvers(), finished: Promise.withResolvers() };
|
|
1279
|
+
const pending = runtime.updateNativeConversationSettings(request);
|
|
1280
|
+
await gate.entered.promise;
|
|
1281
|
+
await runtime.close();
|
|
1282
|
+
gate.finished.resolve();
|
|
1283
|
+
await assert.rejects(pending, { code: "conversation_closed" });
|
|
1284
|
+
await assert.rejects(runtime.updateNativeConversationSettings(request), { code: "conversation_closed" });
|
|
1285
|
+
assert.deepEqual([acquisitions, updates], [1, 1]);
|
|
1286
|
+
});
|
|
@@ -54,6 +54,21 @@ test("the shared element renders delayed sends and supplies retry, edit and canc
|
|
|
54
54
|
await expect(input).toHaveValue("Retain this.");
|
|
55
55
|
await expect(page.getByText("Cancel this question.", { exact: true })).toHaveCount(0);
|
|
56
56
|
await page.goto(`${vite.baseURL}/?delivery=1&queue=1`);
|
|
57
|
+
await send("Admit this first.");
|
|
58
|
+
await send("Keep this queued second.");
|
|
59
|
+
const userRows = page.locator(".assistant-transcript__message--user");
|
|
60
|
+
await expect(userRows.nth(0)).toContainText("Admit this first.");
|
|
61
|
+
await expect(userRows.nth(1)).toContainText("Keep this queued second.");
|
|
62
|
+
await page.getByRole("button", { name: "Accept request" }).click();
|
|
63
|
+
await expect(page.getByText("Pending", { exact: true })).toHaveCount(1);
|
|
64
|
+
await expect(userRows.nth(0)).toContainText("Admit this first.");
|
|
65
|
+
await expect(userRows.nth(1)).toContainText("Keep this queued second.");
|
|
66
|
+
await expect.poll(async () => JSON.parse(await page.locator("[data-requests]").textContent()).length).toBe(2);
|
|
67
|
+
await page.getByRole("button", { name: "Accept request" }).click();
|
|
68
|
+
await expect(page.getByText("Pending", { exact: true })).toHaveCount(0);
|
|
69
|
+
await expect(userRows.nth(0)).toContainText("Admit this first.");
|
|
70
|
+
await expect(userRows.nth(1)).toContainText("Keep this queued second.");
|
|
71
|
+
await page.goto(`${vite.baseURL}/?delivery=1&queue=1`);
|
|
57
72
|
await send("First steer.");
|
|
58
73
|
await send("Second steer.");
|
|
59
74
|
await send("Third steer.");
|
|
@@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
6
7
|
import { createOpenCodeSharedRuntime, ensureOpenCodeSession, openCodeServerForDirectory, readOpenCodeEnvironments } from "../src/server/conversation/openCodeRuntime.js";
|
|
7
8
|
import { createOpenCodeServerClient } from "../src/server/conversation/openCodeClient.js";
|
|
8
9
|
|
|
@@ -13,7 +14,7 @@ function fixture(options = {}) {
|
|
|
13
14
|
const start = async selected => {
|
|
14
15
|
const server = {
|
|
15
16
|
client: {
|
|
16
|
-
async health() { return { healthy: true }; },
|
|
17
|
+
async health(input) { return options.health ? options.health(input) : { healthy: true }; },
|
|
17
18
|
async interrupt(id) { return options.interrupt ? options.interrupt(id) : true; },
|
|
18
19
|
async sessionStatus() { return { type: "idle" }; },
|
|
19
20
|
forDirectory(directory) { return { ...this, directory }; }
|
|
@@ -252,6 +253,75 @@ test("a pending OpenCode session start retains the shared server while another s
|
|
|
252
253
|
assert.equal(f.stops.length, 1);
|
|
253
254
|
});
|
|
254
255
|
|
|
256
|
+
test("a timed-out shared health probe preserves active conversations and allows retry", async () => {
|
|
257
|
+
let slow = false;
|
|
258
|
+
const f = fixture({ health: async ({ signal }) => {
|
|
259
|
+
if (slow) await delay(5_000, undefined, { signal });
|
|
260
|
+
return { healthy: true };
|
|
261
|
+
} });
|
|
262
|
+
const main = await f.acquire("main");
|
|
263
|
+
const helper = await f.acquire("helper");
|
|
264
|
+
const turn = { active: true, id: "turn", threadId: "main-thread" };
|
|
265
|
+
const temporary = { active: true, target: helper, conversationId: "helper-thread" };
|
|
266
|
+
f.runtime.turns.set(main.key, turn);
|
|
267
|
+
f.runtime.temporaryConversations.set("helper-thread", temporary);
|
|
268
|
+
const shared = f.runtime.current;
|
|
269
|
+
slow = true;
|
|
270
|
+
await assert.rejects(f.acquire("next"), { name: "AbortError" });
|
|
271
|
+
assert.equal(f.runtime.current, shared);
|
|
272
|
+
assert.equal(f.stops.length, 0);
|
|
273
|
+
assert.equal(f.starts.length, 1);
|
|
274
|
+
assert.equal(main.abortController.signal.aborted, false);
|
|
275
|
+
assert.equal(helper.abortController.signal.aborted, false);
|
|
276
|
+
assert.equal(f.runtime.turns.get(main.key), turn);
|
|
277
|
+
assert.equal(f.runtime.temporaryConversations.get("helper-thread"), temporary);
|
|
278
|
+
assert.equal(turn.active, true);
|
|
279
|
+
assert.equal(temporary.active, true);
|
|
280
|
+
assert.equal(f.runtime.processStarts.size, 0);
|
|
281
|
+
slow = false;
|
|
282
|
+
await f.acquire("next");
|
|
283
|
+
assert.equal(f.starts.length, 1);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("a timed-out readiness probe does not classify the shared process as unhealthy", async () => {
|
|
287
|
+
let slow = false;
|
|
288
|
+
const f = fixture({ health: async ({ signal }) => {
|
|
289
|
+
if (slow) await delay(5_000, undefined, { signal });
|
|
290
|
+
return { healthy: true };
|
|
291
|
+
} });
|
|
292
|
+
const target = await f.acquire("main");
|
|
293
|
+
const selection = { modelProviderId: "provider", modelId: "model", catalogRevision: "catalog" };
|
|
294
|
+
Object.assign(target, { upstream: { id: "thread" }, upstreamSessionId: "thread", selection,
|
|
295
|
+
upstreamSelection: selection, canonicalUrl: f.connection.canonicalUrl,
|
|
296
|
+
connectionFingerprint: f.connection.fingerprint, endpointCode: f.connection.endpointCode });
|
|
297
|
+
target.server.client.readSession = async () => ({ id: "thread" });
|
|
298
|
+
const input = { selection, workdir: target.workdir, connection: f.connection,
|
|
299
|
+
refresh: async () => target, changed: () => new Error("Changed") };
|
|
300
|
+
slow = true;
|
|
301
|
+
await assert.rejects(f.runtime.inspectSessionReadiness(target, input), { name: "AbortError" });
|
|
302
|
+
assert.equal(f.stops.length, 0);
|
|
303
|
+
assert.equal(target.abortController.signal.aborted, false);
|
|
304
|
+
slow = false;
|
|
305
|
+
assert.deepEqual(await f.runtime.inspectSessionReadiness(target, input), {
|
|
306
|
+
ready: true, unhealthyProcess: null, missingUpstream: false
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("a refused shared health connection still replaces the stopped server", async () => {
|
|
311
|
+
let refused = false;
|
|
312
|
+
const f = fixture({ health: async () => {
|
|
313
|
+
if (refused) throw new TypeError("fetch failed", { cause: { code: "ECONNREFUSED" } });
|
|
314
|
+
return { healthy: true };
|
|
315
|
+
} });
|
|
316
|
+
await f.acquire("first");
|
|
317
|
+
const original = f.runtime.current;
|
|
318
|
+
refused = true;
|
|
319
|
+
await f.acquire("next");
|
|
320
|
+
assert.equal(f.stops.length, 1);
|
|
321
|
+
assert.equal(f.starts.length, 2);
|
|
322
|
+
assert.notEqual(f.runtime.current, original);
|
|
323
|
+
});
|
|
324
|
+
|
|
255
325
|
test("OpenCode failed acquisition cleans application state once before stopping an unused server", async () => {
|
|
256
326
|
const f = fixture();
|
|
257
327
|
const ready = Promise.withResolvers();
|