@jskit-ai/assistant-core 0.1.223 → 0.1.224
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/package.json +1 -1
- package/src/client/conversation/AssistantGoalControl.vue +8 -8
- 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/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/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/conversationSession.test.js +48 -0
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|