@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
package/test/claudeTurn.test.js
CHANGED
|
@@ -51,6 +51,72 @@ test("steering waits for the generation's terminal event after the control ackno
|
|
|
51
51
|
assert.equal(turn.read().status, "completed");
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
test("Claude native follow-ups reactivate a completed turn without admitting another human message", async () => {
|
|
55
|
+
for (const start of [
|
|
56
|
+
{ type: "user", uuid: "notification", origin: { kind: "task-notification" } },
|
|
57
|
+
{ type: "stream_event", event: { type: "message_start", message: { id: "continued-message" } } },
|
|
58
|
+
{ type: "assistant", uuid: "continued-answer", message: { content: [{ type: "text", text: "Still working" }] } }
|
|
59
|
+
]) {
|
|
60
|
+
const { turn, send, events } = fixture();
|
|
61
|
+
await send();
|
|
62
|
+
await turn.receive({ type: "result", subtype: "success", result: "Background work launched" });
|
|
63
|
+
assert.equal(turn.read().active, false);
|
|
64
|
+
const admitted = events.filter(event => event.type === "admitted").length;
|
|
65
|
+
await turn.receive(start);
|
|
66
|
+
assert.equal(turn.read().active, true);
|
|
67
|
+
assert.equal(turn.read().status, "inProgress");
|
|
68
|
+
assert.equal(turn.read().text, "");
|
|
69
|
+
assert.equal(events.filter(event => event.type === "continued").length, 1);
|
|
70
|
+
assert.equal(events.filter(event => event.type === "admitted").length, admitted);
|
|
71
|
+
await turn.receive({ type: "system", subtype: "status", status: null });
|
|
72
|
+
assert.equal(turn.read().active, true, "The previous result cannot complete the follow-up");
|
|
73
|
+
await turn.receive({ type: "result", subtype: "success", result: "Background work finished" });
|
|
74
|
+
assert.equal(turn.read().active, false);
|
|
75
|
+
assert.equal(turn.read().text, "Background work finished");
|
|
76
|
+
turn.stop();
|
|
77
|
+
await turn.receive(start);
|
|
78
|
+
assert.equal(turn.read().active, false, "Late native frames cannot restart stopped work");
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("Claude authoritative task snapshots repair membership and ignore ambient work", async () => {
|
|
83
|
+
const { turn, send, events } = fixture();
|
|
84
|
+
await send();
|
|
85
|
+
await turn.receive({ type: "system", subtype: "task_started", task_id: "missed-completion", task_type: "local_agent" });
|
|
86
|
+
await turn.receive({ type: "system", subtype: "background_tasks_changed", tasks: [
|
|
87
|
+
{ task_id: "build", task_type: "local_bash" }, { task_id: "remote", task_type: "remote_agent" },
|
|
88
|
+
{ task_id: "watcher", task_type: "local_bash", ambient: true }
|
|
89
|
+
] });
|
|
90
|
+
assert.equal(turn.read().backgroundTasks, 2);
|
|
91
|
+
await turn.receive({ type: "result", subtype: "success", result: "Launched" });
|
|
92
|
+
assert.equal(turn.read().active, true);
|
|
93
|
+
await turn.receive({ type: "system", subtype: "background_tasks_changed", tasks: [
|
|
94
|
+
{ task_id: "watcher", task_type: "local_bash", ambient: true }
|
|
95
|
+
] });
|
|
96
|
+
assert.equal(turn.read().active, false);
|
|
97
|
+
await turn.receive({ type: "system", subtype: "status", status: null });
|
|
98
|
+
await turn.receive({ type: "system", subtype: "background_tasks_changed", tasks: [] });
|
|
99
|
+
assert.equal(events.filter(event => event.type === "settled").length, 1);
|
|
100
|
+
await turn.receive({ type: "system", subtype: "background_tasks_changed", tasks: [{ task_id: "late", task_type: "local_bash" }] });
|
|
101
|
+
assert.equal(turn.read().active, true, "A later task snapshot restores the working state");
|
|
102
|
+
await turn.receive({ type: "system", subtype: "task_updated", task_id: "late", patch: { status: "completed" } });
|
|
103
|
+
assert.equal(turn.read().active, false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("Claude can steer background-only work without waiting for an already completed generation", async () => {
|
|
107
|
+
const { turn, send, client } = fixture();
|
|
108
|
+
await send();
|
|
109
|
+
await turn.receive({ type: "system", subtype: "task_started", task_id: "build", task_type: "local_bash" });
|
|
110
|
+
await turn.receive({ type: "result", subtype: "success", result: "Build started" });
|
|
111
|
+
client.interrupt = () => assert.fail("There is no active generation to interrupt");
|
|
112
|
+
await turn.interruptGeneration(client, { timeoutMs: 5 });
|
|
113
|
+
await send("follow-up");
|
|
114
|
+
await turn.receive({ type: "system", subtype: "task_notification", task_id: "build", status: "completed" });
|
|
115
|
+
assert.equal(turn.read().active, true, "The new human input still needs its own result");
|
|
116
|
+
await turn.receive({ type: "result", subtype: "success", result: "Revised answer" });
|
|
117
|
+
assert.equal(turn.read().status, "completed");
|
|
118
|
+
});
|
|
119
|
+
|
|
54
120
|
test("interruption does not claim process exit or complete work on a control acknowledgement", async () => {
|
|
55
121
|
const { turn, send, client, events } = fixture();
|
|
56
122
|
await send();
|
|
@@ -132,6 +198,7 @@ function renewalFixture() {
|
|
|
132
198
|
|
|
133
199
|
test("renewal recovers the exact accepted native answer without another dispatch and closes before returning", async () => {
|
|
134
200
|
const f = renewalFixture();
|
|
201
|
+
f.state.turn = { id: f.uuid, state: "completed", active: false };
|
|
135
202
|
f.history.userIds.push(f.uuid);
|
|
136
203
|
f.history.messages.push({ userId: "other", role: "assistant", text: "Unrelated" },
|
|
137
204
|
{ userId: f.uuid, role: "thinking", text: "Private reasoning" },
|
|
@@ -142,6 +209,28 @@ test("renewal recovers the exact accepted native answer without another dispatch
|
|
|
142
209
|
assert.deepEqual(f.events, ["history", ["stop", undefined]]);
|
|
143
210
|
});
|
|
144
211
|
|
|
212
|
+
test("renewal never treats partial or unrelated accepted output as completed", async () => {
|
|
213
|
+
for (const state of ["active", "failed", "interrupted", "completed"]) {
|
|
214
|
+
const f = renewalFixture();
|
|
215
|
+
f.history.userIds.push(f.uuid);
|
|
216
|
+
f.history.messages.push({ userId: f.uuid, role: "assistant", text: "A plausible but unconfirmed answer" });
|
|
217
|
+
f.state.turn = { id: state === "completed" ? "other-turn" : f.uuid, state, active: state === "active" };
|
|
218
|
+
await assert.rejects(runClaudeRenewalTurn(f.conversation, f.input), error =>
|
|
219
|
+
error.code === "assistant_claude_turn_unreadable" && error.details.inputAccepted === true);
|
|
220
|
+
assert.deepEqual(f.events.map(event => Array.isArray(event) ? event[0] : event), ["history", "stop"]);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("renewal joins an accepted running turn before stopping without resubmitting", async () => {
|
|
225
|
+
const f = renewalFixture();
|
|
226
|
+
f.history.userIds.push(f.uuid);
|
|
227
|
+
f.state.turn = { id: f.uuid, state: "active", active: true };
|
|
228
|
+
f.state.process = {};
|
|
229
|
+
const result = await runClaudeRenewalTurn(f.conversation, f.input);
|
|
230
|
+
assert.equal(result.text, "Finished");
|
|
231
|
+
assert.deepEqual(f.events, ["history", ["wait", { conversationId: f.state.id, timeoutMs: 100 }], ["stop", undefined]]);
|
|
232
|
+
});
|
|
233
|
+
|
|
145
234
|
test("renewal refuses changed or unrelated successor history before native work and never resends unreadable accepted input", async () => {
|
|
146
235
|
const f = renewalFixture();
|
|
147
236
|
await assert.rejects(runClaudeRenewalTurn(f.conversation, { ...f.input, expectedThreadId: "different" }), /expected native history/);
|
|
@@ -229,7 +318,7 @@ test("wait interrupts on its bounded timeout or caller abort without replacing t
|
|
|
229
318
|
assert.equal(interrupts, 2);
|
|
230
319
|
});
|
|
231
320
|
|
|
232
|
-
test("Claude split completions
|
|
321
|
+
test("Claude split completions retain exact output identity until the message role is known", async () => {
|
|
233
322
|
const { turn, events, send } = fixture();
|
|
234
323
|
await send();
|
|
235
324
|
await turn.receive({ type: "stream_event", event: { type: "message_start", message: { id: "same-api-message" } } });
|
|
@@ -241,6 +330,12 @@ test("Claude split completions carry the exact live output identity before nativ
|
|
|
241
330
|
delta: block.type === "thinking" ? { thinking: block.thinking } : { text: block.text } } });
|
|
242
331
|
// Claude's documented wire order completes the split frame before its stop.
|
|
243
332
|
await turn.receive({ type: "assistant", uuid: `saved-block-${index}`, message: { id: "same-api-message", content: [block] } });
|
|
333
|
+
if (block.type === "text") {
|
|
334
|
+
assert.equal(events.filter(event => event.type === "message" && event.message.complete && event.message.role === "assistant").length, 0);
|
|
335
|
+
await turn.receive({ type: "stream_event", event: { type: "content_block_stop", index } });
|
|
336
|
+
assert.ok(turn.read().messages.some(message => message.id === `claude_same-api-message_${index}`));
|
|
337
|
+
await turn.receive({ type: "stream_event", event: { type: "message_delta", delta: { stop_reason: "end_turn" } } });
|
|
338
|
+
}
|
|
244
339
|
const complete = events.filter(event => event.type === "message" && event.message.complete).at(-1).message;
|
|
245
340
|
assert.equal(complete.id, `claude_saved-block-${index}_0`, "Native history keeps its frame UUID and local index");
|
|
246
341
|
assert.equal(complete.outputId, `claude_same-api-message_${index}`, "The existing live map supplies the global block index");
|
|
@@ -263,6 +358,7 @@ test("Claude output identity is absent without unique live proof or with a confl
|
|
|
263
358
|
id: mode === "missing-id" ? undefined : mode === "foreign" ? "other" : "native", content: [{ type: "text", text: "Complete" }]
|
|
264
359
|
}, ...(mode === "wrong-index" ? { apiBlockIndex: 4 } : mode === "invalid-index" ? { apiBlockIndex: "3" }
|
|
265
360
|
: mode === "explicit-index" ? { apiBlockIndex: 3 } : {}) });
|
|
361
|
+
await turn.receive({ type: "result", subtype: "success", result: "" });
|
|
266
362
|
const message = events.filter(event => event.type === "message").at(-1).message;
|
|
267
363
|
assert.equal(message.id, "claude_saved_0");
|
|
268
364
|
if (mode === "explicit-index") assert.equal(message.outputId, "claude_native_3");
|
|
@@ -270,6 +366,20 @@ test("Claude output identity is absent without unique live proof or with a confl
|
|
|
270
366
|
}
|
|
271
367
|
});
|
|
272
368
|
|
|
369
|
+
test("Claude split text followed by a tool is commentary, never a final answer", async () => {
|
|
370
|
+
const { turn, events, send } = fixture();
|
|
371
|
+
await send();
|
|
372
|
+
await turn.receive({ type: "assistant", uuid: "progress", message: { id: "tool-message", content: [{ type: "text", text: "Checking files" }] } });
|
|
373
|
+
assert.equal(events.some(event => event.type === "message" && event.message.complete), false);
|
|
374
|
+
await turn.receive({ type: "assistant", uuid: "tool", message: { id: "tool-message", content: [{ type: "tool_use", id: "read", name: "Read", input: {} }] } });
|
|
375
|
+
const progress = events.filter(event => event.type === "message").at(-1).message;
|
|
376
|
+
assert.equal(progress.role, "commentary");
|
|
377
|
+
assert.equal(progress.id, "claude_progress_0");
|
|
378
|
+
await turn.receive({ type: "assistant", uuid: "answer", message: { id: "final-message", content: [{ type: "text", text: "Done" }] } });
|
|
379
|
+
await turn.receive({ type: "result", subtype: "success", result: "Done" });
|
|
380
|
+
assert.deepEqual(events.filter(event => event.type === "message" && event.message.role === "assistant").map(event => event.message.text), ["Done"]);
|
|
381
|
+
});
|
|
382
|
+
|
|
273
383
|
|
|
274
384
|
import { createClaudeConversationOwner } from "../src/server/conversation/claudeTurn.js";
|
|
275
385
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
@@ -302,7 +412,7 @@ async function publishedClaudeFixture(t, { publication = true, providerId = "ant
|
|
|
302
412
|
context: async () => context,
|
|
303
413
|
entry: () => ({ options: { mainId: conversationId }, check() {} }),
|
|
304
414
|
check() {}, account: () => ({ identity: behavior.account, providerId }),
|
|
305
|
-
async configuration() { return { systemPrompt: "Retained host instructions", settings: {}, model: providerId === "deepseek" ? "deepseek-flash" : "sonnet" }; },
|
|
415
|
+
async configuration() { await behavior.configure?.(); return { systemPrompt: "Retained host instructions", settings: {}, model: providerId === "deepseek" ? "deepseek-flash" : "sonnet" }; },
|
|
306
416
|
async message(_entry, _input, { message }) { return message; }
|
|
307
417
|
},
|
|
308
418
|
process: {
|
|
@@ -319,7 +429,8 @@ async function publishedClaudeFixture(t, { publication = true, providerId = "ant
|
|
|
319
429
|
} }
|
|
320
430
|
};
|
|
321
431
|
processes.push(native);
|
|
322
|
-
await options.onStarted(native.executionId);
|
|
432
|
+
await options.onStarted(native.executionId, () => native.stop());
|
|
433
|
+
await behavior.initialize?.(native);
|
|
323
434
|
return native;
|
|
324
435
|
}
|
|
325
436
|
},
|
|
@@ -352,6 +463,55 @@ async function publishedClaudeFixture(t, { publication = true, providerId = "ant
|
|
|
352
463
|
async reopen() { entry = await owner.open(context, { mainId: conversationId }); return entry; } };
|
|
353
464
|
}
|
|
354
465
|
|
|
466
|
+
test("Claude continuation supersedes an older completion checkpoint", { timeout: 10_000 }, async t => {
|
|
467
|
+
const f = await publishedClaudeFixture(t);
|
|
468
|
+
await f.send();
|
|
469
|
+
const entered = Promise.withResolvers();
|
|
470
|
+
const release = Promise.withResolvers();
|
|
471
|
+
let checkpoints = 0;
|
|
472
|
+
f.setBeforeState(async (_entry, event) => {
|
|
473
|
+
if (event.state === "completed" && ++checkpoints === 1) {
|
|
474
|
+
entered.resolve();
|
|
475
|
+
await release.promise;
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
const oldResult = f.frame({ type: "result", subtype: "success", result: "First answer" });
|
|
479
|
+
await entered.promise;
|
|
480
|
+
const originalWaiter = f.entry.completion.promise;
|
|
481
|
+
await f.frame({ type: "user", uuid: "background-notification", origin: { kind: "task-notification" } });
|
|
482
|
+
const completion = f.entry.completion;
|
|
483
|
+
release.resolve();
|
|
484
|
+
await oldResult;
|
|
485
|
+
assert.equal(f.entry.turn.state, "active");
|
|
486
|
+
assert.equal(f.entry.turn.active, true);
|
|
487
|
+
assert.equal(f.entry.completion, completion);
|
|
488
|
+
await f.frame({ type: "result", subtype: "success", result: "Follow-up answer" });
|
|
489
|
+
assert.equal((await completion.promise).text, "Follow-up answer");
|
|
490
|
+
assert.equal((await originalWaiter).text, "Follow-up answer");
|
|
491
|
+
assert.equal(f.entry.turn.state, "completed");
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test("Claude concurrent cleanup callers join the same process stop and interrupted checkpoint", { timeout: 3_000 }, async t => {
|
|
495
|
+
const f = await publishedClaudeFixture(t);
|
|
496
|
+
await f.send();
|
|
497
|
+
const entered = Promise.withResolvers();
|
|
498
|
+
const release = Promise.withResolvers();
|
|
499
|
+
let stops = 0;
|
|
500
|
+
f.entry.process.stop = async () => { stops++; return { scopeEmpty: true }; };
|
|
501
|
+
f.setBeforeState(async (_entry, event) => {
|
|
502
|
+
if (event.state === "interrupted") { entered.resolve(); await release.promise; }
|
|
503
|
+
});
|
|
504
|
+
const first = f.entry.nativeTurn.stopProcess("Observation lost");
|
|
505
|
+
await entered.promise;
|
|
506
|
+
const second = f.entry.nativeTurn.stopProcess("Concurrent Stop");
|
|
507
|
+
assert.equal(stops, 1);
|
|
508
|
+
release.resolve();
|
|
509
|
+
await Promise.all([first, second]);
|
|
510
|
+
assert.equal(f.entry.turn.active, false);
|
|
511
|
+
assert.equal(f.entry.turn.state, "interrupted");
|
|
512
|
+
assert.equal(f.entry.turn.error, "Observation lost");
|
|
513
|
+
});
|
|
514
|
+
|
|
355
515
|
test("Claude final receipt uses the original supplied-store publication and is available before retained idle checkpoint", async t => {
|
|
356
516
|
const f = await publishedClaudeFixture(t);
|
|
357
517
|
await f.send();
|
|
@@ -822,6 +982,31 @@ async function sendCommon(f, messageId) {
|
|
|
822
982
|
return receipt;
|
|
823
983
|
}
|
|
824
984
|
|
|
985
|
+
test("Claude fast follow-up completes while the earlier common checkpoint is held", { timeout: 3_000 }, async t => {
|
|
986
|
+
const f = await suppliedCommonFixture(t);
|
|
987
|
+
await sendCommon(f, "checkpoint-follow-up");
|
|
988
|
+
const entered = Promise.withResolvers();
|
|
989
|
+
const release = Promise.withResolvers();
|
|
990
|
+
let checkpoints = 0;
|
|
991
|
+
f.setBeforeState(async (_entry, event) => {
|
|
992
|
+
if (event.state === "completed" && ++checkpoints === 1) {
|
|
993
|
+
entered.resolve();
|
|
994
|
+
await release.promise;
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
const waiting = f.entry.completion.promise;
|
|
998
|
+
await f.frame({ type: "result", subtype: "success", result: "First answer" });
|
|
999
|
+
await entered.promise;
|
|
1000
|
+
try {
|
|
1001
|
+
await f.frame({ type: "user", uuid: "automatic", origin: { kind: "task-notification" } });
|
|
1002
|
+
await f.frame({ type: "result", subtype: "success", result: "Latest answer" });
|
|
1003
|
+
} finally { release.resolve(); }
|
|
1004
|
+
await f.conversation.wait();
|
|
1005
|
+
assert.equal(f.entry.turn.state, "completed");
|
|
1006
|
+
assert.equal((await waiting).text, "Latest answer");
|
|
1007
|
+
assert.equal(f.entry.process.stopped, undefined);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
825
1010
|
test("Claude delivery timing separates native acknowledgement from the held canonical receipt", { timeout: 10_000 }, async t => {
|
|
826
1011
|
const timings = [];
|
|
827
1012
|
const f = await suppliedCommonFixture(t, { onDeliveryTiming: event => timings.push(event) });
|
|
@@ -958,3 +1143,68 @@ test("Claude supplied common checkpoint failure preserves original refusal and n
|
|
|
958
1143
|
assert.equal(f.entry.command, null);
|
|
959
1144
|
assert.equal(f.entry.turn.active, false);
|
|
960
1145
|
});
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
test("Claude failed Stop retains cleanup for an explicit retry without a hidden second attempt", async t => {
|
|
1149
|
+
const f = await claudeSuppliedToolFixture(t);
|
|
1150
|
+
const running = f.owner.run(f.control, f.command("stop-retry"), f.options);
|
|
1151
|
+
const failedRun = assert.rejects(running, error => error.cleanupFailed === true);
|
|
1152
|
+
while (!f.accepted.length) await new Promise(resolve => setImmediate(resolve));
|
|
1153
|
+
let stops = 0;
|
|
1154
|
+
let allowed = false;
|
|
1155
|
+
t.after(() => { allowed = true; });
|
|
1156
|
+
f.entry.process.stop = async () => { stops++; return { scopeEmpty: allowed, exited: allowed }; };
|
|
1157
|
+
await assert.rejects(f.owner.cancel(f.control, { context: f.context, ...f.options }), { code: "claude_stop_unconfirmed" });
|
|
1158
|
+
await failedRun;
|
|
1159
|
+
assert.equal(stops, 1, "Turn failure must not retry unconfirmed cleanup behind the caller");
|
|
1160
|
+
allowed = true;
|
|
1161
|
+
await f.owner.cancel(f.control, { context: f.context, ...f.options });
|
|
1162
|
+
assert.equal(stops, 2);
|
|
1163
|
+
assert.equal(f.entry.turn.active, false);
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
for (const scope of ["session", "conversation"]) test(`Claude ${scope} close stops pending initialization before waiting for preparation`, { timeout: 3000 }, async t => {
|
|
1168
|
+
const f = await publishedClaudeFixture(t);
|
|
1169
|
+
const entered = Promise.withResolvers();
|
|
1170
|
+
const initialization = Promise.withResolvers();
|
|
1171
|
+
f.behavior.initialize = async native => {
|
|
1172
|
+
native.stop = async () => {
|
|
1173
|
+
initialization.reject(new Error("Startup stopped"));
|
|
1174
|
+
return { scopeEmpty: true, exited: true };
|
|
1175
|
+
};
|
|
1176
|
+
entered.resolve();
|
|
1177
|
+
await initialization.promise;
|
|
1178
|
+
};
|
|
1179
|
+
const preparing = f.owner.ensureProcess(f.entry);
|
|
1180
|
+
const rejected = assert.rejects(preparing, /Startup stopped/);
|
|
1181
|
+
await entered.promise;
|
|
1182
|
+
const closed = scope === "conversation" ? f.owner.close(f.entry) :
|
|
1183
|
+
f.owner.closeSession(f.context, { readContext: async () => f.context,
|
|
1184
|
+
terminals: { close: async () => ({ ok: true, closed: 0 }) } });
|
|
1185
|
+
let timer;
|
|
1186
|
+
try {
|
|
1187
|
+
await Promise.race([closed, new Promise((_, reject) => {
|
|
1188
|
+
timer = setTimeout(() => reject(new Error("Session close waited for initialization before stopping it")), 300);
|
|
1189
|
+
})]);
|
|
1190
|
+
await rejected;
|
|
1191
|
+
assert.equal(f.entry.executionId, "");
|
|
1192
|
+
} finally {
|
|
1193
|
+
clearTimeout(timer);
|
|
1194
|
+
initialization.reject(new Error("Startup stopped"));
|
|
1195
|
+
await Promise.allSettled([closed, preparing, rejected]);
|
|
1196
|
+
}
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
test("Claude Stop cancels configuration that has not started its native process", async t => {
|
|
1200
|
+
const f = await publishedClaudeFixture(t);
|
|
1201
|
+
const entered = Promise.withResolvers();
|
|
1202
|
+
const release = Promise.withResolvers();
|
|
1203
|
+
f.behavior.configure = async () => { entered.resolve(); await release.promise; };
|
|
1204
|
+
const preparing = f.owner.ensureProcess(f.entry);
|
|
1205
|
+
await entered.promise;
|
|
1206
|
+
await f.entry.nativeTurn.stopProcess();
|
|
1207
|
+
release.resolve();
|
|
1208
|
+
await assert.rejects(preparing, /stopped|closing/);
|
|
1209
|
+
assert.equal(f.processes.length, 0, "Stopped configuration must not launch Claude later");
|
|
1210
|
+
});
|
|
@@ -205,7 +205,7 @@ test("Codex goal controls preserve native scheduling without authored command me
|
|
|
205
205
|
const state = await f.conversation.read();
|
|
206
206
|
assert.deepEqual(state.capabilities.goalCommands, {
|
|
207
207
|
set: { delivery: "control", interruptsTurn: false }, resume: { delivery: "control", interruptsTurn: false },
|
|
208
|
-
pause: { delivery: "control", interruptsTurn:
|
|
208
|
+
pause: { delivery: "control", interruptsTurn: true }, cancel: { delivery: "control", interruptsTurn: false }
|
|
209
209
|
});
|
|
210
210
|
const command = { action: "set", objective: "Finish the goal", expectedSegmentId: state.segmentId, tokenBudget: 40 };
|
|
211
211
|
const events = [];
|
|
@@ -400,6 +400,30 @@ test("a native create_goal retains its authored owner and recovers only the exac
|
|
|
400
400
|
assert.equal((await f.trace()).some(row => row.method === "thread/goal/set"), false);
|
|
401
401
|
});
|
|
402
402
|
|
|
403
|
+
test("Pause goal stops the active Codex conversation and preserves its goal for Resume", async t => {
|
|
404
|
+
const f = await fixture(t);
|
|
405
|
+
const { segmentId } = await f.conversation.read();
|
|
406
|
+
const goal = await f.conversation.updateGoal({ action: "set", objective: "wait goal", tokenBudget: 100,
|
|
407
|
+
expectedSegmentId: segmentId });
|
|
408
|
+
assert.equal((await f.conversation.read()).status, "working");
|
|
409
|
+
const paused = await f.conversation.updateGoal({ action: "pause", expectedGoalId: goal.id, expectedSegmentId: segmentId });
|
|
410
|
+
assert.equal(paused.id, goal.id);
|
|
411
|
+
assert.equal(paused.objective, goal.objective);
|
|
412
|
+
assert.equal(paused.tokenBudget, goal.tokenBudget);
|
|
413
|
+
assert.equal(paused.status, "paused");
|
|
414
|
+
assert.equal((await f.conversation.read()).status, "ready");
|
|
415
|
+
const trace = await f.trace();
|
|
416
|
+
const pause = trace.findIndex(row => row.method === "thread/goal/set" && row.params.status === "paused");
|
|
417
|
+
const stop = trace.findIndex(row => row.method === "turn/interrupt");
|
|
418
|
+
assert.ok(pause >= 0 && stop > pause, "goal scheduling stops before interrupting the active turn");
|
|
419
|
+
assert.equal(trace.filter(row => row.method === "turn/interrupt").length, 1);
|
|
420
|
+
assert.equal(trace.some(row => row.method === "thread/goal/clear"), false);
|
|
421
|
+
await f.conversation.updateGoal({ action: "resume", expectedGoalId: goal.id, expectedSegmentId: segmentId });
|
|
422
|
+
const resumed = await f.conversation.readGoal();
|
|
423
|
+
assert.equal(resumed.id, goal.id);
|
|
424
|
+
assert.equal(resumed.status, "active");
|
|
425
|
+
});
|
|
426
|
+
|
|
403
427
|
test("Codex goal pause, process restart, resume and clear retain the same goal identity", async t => {
|
|
404
428
|
const f = await fixture(t);
|
|
405
429
|
const { segmentId } = await f.conversation.read();
|
|
@@ -735,9 +759,8 @@ test("a paused goal stays with its native conversation when another engine is se
|
|
|
735
759
|
await f.conversation.updateGoal({ action: "set", messageId: "retained-goal", objective: "wait goal", expectedSegmentId: segmentId });
|
|
736
760
|
const goal = await f.conversation.readGoal();
|
|
737
761
|
await f.conversation.updateGoal({ action: "pause", expectedGoalId: goal.id, expectedSegmentId: segmentId });
|
|
738
|
-
assert.equal((await f.conversation.read()).status, "
|
|
739
|
-
assert.equal((await f.trace()).
|
|
740
|
-
await f.conversation.cancel();
|
|
762
|
+
assert.equal((await f.conversation.read()).status, "ready");
|
|
763
|
+
assert.equal((await f.trace()).filter(row => row.method === "turn/interrupt").length, 1);
|
|
741
764
|
await select(f.conversation, "api", "to-api");
|
|
742
765
|
assert.equal((await f.conversation.read()).goal, null);
|
|
743
766
|
assert.equal(await f.conversation.readGoal(), null);
|
package/test/codexGoal.test.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { CodexAppServerAgentProvider } from "../src/server/conversation/codexProvider.js";
|
|
3
4
|
import { createCodexAppServerProviderOwner } from "../src/server/conversation/codexProviderOwner.js";
|
|
4
5
|
|
|
5
6
|
test("cached goal reads retain the original session and interactive-provider boundary", async t => {
|
|
@@ -35,3 +36,154 @@ test("cached goal reads retain the original session and interactive-provider bou
|
|
|
35
36
|
interactive.readGoal = async () => { throw failure; };
|
|
36
37
|
await assert.rejects(owner.readSessionGoal("one", "thread-one"), error => error === failure);
|
|
37
38
|
});
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
test("next-turn Codex settings wait for their applied snapshot and release observation on failure", async t => {
|
|
42
|
+
const provider = new CodexAppServerAgentProvider({}, { credentials: { assertCurrent() {} } });
|
|
43
|
+
t.after(() => provider.close());
|
|
44
|
+
const requests = [];
|
|
45
|
+
const observers = new Set();
|
|
46
|
+
let queued = Promise.withResolvers();
|
|
47
|
+
let reject = false;
|
|
48
|
+
provider.client = { isOpen: () => true, close() {},
|
|
49
|
+
subscribe(callback) { observers.add(callback); return () => observers.delete(callback); },
|
|
50
|
+
async request(method, params, options) {
|
|
51
|
+
requests.push({ method, params });
|
|
52
|
+
assert.equal(options.signal.aborted, false);
|
|
53
|
+
if (method === "thread/read") return { thread: { model: "old-model", reasoningEffort: "low" } };
|
|
54
|
+
queued.resolve();
|
|
55
|
+
if (reject) throw new Error("Native settings rejected");
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
const publish = (threadId, model, effort) => {
|
|
60
|
+
for (const observer of observers) observer({ method: "thread/settings/updated", params: {
|
|
61
|
+
threadId, threadSettings: { model, effort }
|
|
62
|
+
} });
|
|
63
|
+
};
|
|
64
|
+
let settled = false;
|
|
65
|
+
const pending = provider.updateThreadSettings("thread-one", { model: "model-two", effort: "high" }).then(result => {
|
|
66
|
+
settled = true;
|
|
67
|
+
return result;
|
|
68
|
+
});
|
|
69
|
+
await queued.promise;
|
|
70
|
+
publish("other-thread", "model-two", "high");
|
|
71
|
+
publish("thread-one", "old-model", "high");
|
|
72
|
+
publish("thread-one", "model-two", "low");
|
|
73
|
+
await Promise.resolve();
|
|
74
|
+
assert.equal(settled, false, "An acknowledgement or unrelated settings snapshot cannot confirm this change");
|
|
75
|
+
publish("thread-one", "model-two", "high");
|
|
76
|
+
assert.deepEqual(await pending, {});
|
|
77
|
+
assert.equal(observers.size, 0);
|
|
78
|
+
assert.deepEqual(requests, [{ method: "thread/read", params: { threadId: "thread-one", includeTurns: false } },
|
|
79
|
+
{ method: "thread/settings/update", params: {
|
|
80
|
+
threadId: "thread-one", model: "model-two", effort: "high"
|
|
81
|
+
} }]);
|
|
82
|
+
await assert.rejects(provider.updateThreadSettings("", { model: "model" }), /existing thread/);
|
|
83
|
+
await assert.rejects(provider.updateThreadSettings("thread-one", { model: "" }), /existing thread/);
|
|
84
|
+
assert.equal(requests.length, 2);
|
|
85
|
+
reject = true;
|
|
86
|
+
await assert.rejects(provider.updateThreadSettings("thread-one", { model: "model-two" }), /Native settings rejected/);
|
|
87
|
+
assert.equal(observers.size, 0);
|
|
88
|
+
reject = false;
|
|
89
|
+
queued = Promise.withResolvers();
|
|
90
|
+
const closing = provider.updateThreadSettings("thread-one", { model: "model-three" });
|
|
91
|
+
await queued.promise;
|
|
92
|
+
provider.close();
|
|
93
|
+
await assert.rejects(closing, /closed before confirming/);
|
|
94
|
+
assert.equal(observers.size, 0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("already-applied Codex settings need no update event", async t => {
|
|
98
|
+
const provider = new CodexAppServerAgentProvider({}, { credentials: { assertCurrent() {} } });
|
|
99
|
+
t.after(() => provider.close());
|
|
100
|
+
let observing = 0;
|
|
101
|
+
provider.client = { isOpen: () => true, close() {},
|
|
102
|
+
subscribe() { observing++; return () => { observing--; }; },
|
|
103
|
+
async request(method, params) {
|
|
104
|
+
assert.equal(method, "thread/read", "Matching native settings must not enqueue a redundant update");
|
|
105
|
+
assert.equal(params.threadId, "thread-one");
|
|
106
|
+
return { thread: { model: "model-one", reasoningEffort: "high" } };
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
assert.deepEqual(await provider.updateThreadSettings("thread-one", { model: "model-one", effort: "high" }), {});
|
|
110
|
+
assert.equal(observing, 0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
for (const pause of [false, true]) {
|
|
114
|
+
test(`provider settings retain native goal identity across reload${pause ? " and user Pause" : ""}`, async t => {
|
|
115
|
+
let credentialsRevision = 1;
|
|
116
|
+
const prepared = [];
|
|
117
|
+
const provider = new CodexAppServerAgentProvider({
|
|
118
|
+
prepareThreadEnvironment: async environment => environment,
|
|
119
|
+
prepareThreadParams: async params => {
|
|
120
|
+
prepared.push(credentialsRevision);
|
|
121
|
+
return { ...params, modelProvider: "deepseek", config: { ...params.config,
|
|
122
|
+
"model_providers.deepseek": { testRevision: credentialsRevision } } };
|
|
123
|
+
}
|
|
124
|
+
}, { credentials: { assertCurrent() {} } });
|
|
125
|
+
t.after(() => provider.close());
|
|
126
|
+
const observers = new Set();
|
|
127
|
+
const requests = [];
|
|
128
|
+
let status = "active", modelProvider = "openai";
|
|
129
|
+
let goal = { status: "active", createdAt: 1, updatedAt: 1, objective: "Finish validation", tokenBudget: 100, tokensUsed: 40 };
|
|
130
|
+
let publishSettings;
|
|
131
|
+
const submitted = Promise.withResolvers();
|
|
132
|
+
const emit = notification => {
|
|
133
|
+
for (const observer of observers) observer(notification);
|
|
134
|
+
provider.publishNotification(notification);
|
|
135
|
+
};
|
|
136
|
+
provider.client = { isOpen: () => true, close() {},
|
|
137
|
+
subscribe(callback) { observers.add(callback); return () => observers.delete(callback); },
|
|
138
|
+
async request(method, params) {
|
|
139
|
+
requests.push({ method, params });
|
|
140
|
+
if (method === "thread/read") return { thread: { status, modelProvider, historyMode: "paginated" } };
|
|
141
|
+
if (method === "thread/goal/get") return { goal: goal && { ...goal } };
|
|
142
|
+
if (method === "thread/goal/set") {
|
|
143
|
+
goal = { ...goal, status: params.status, updatedAt: goal.updatedAt + 1 };
|
|
144
|
+
emit({ method: "thread/goal/updated", params: { threadId: "native", goal: { ...goal } } });
|
|
145
|
+
return { goal: { ...goal } };
|
|
146
|
+
}
|
|
147
|
+
if (method === "thread/unsubscribe") { status = "notLoaded"; return { status: "unsubscribed" }; }
|
|
148
|
+
if (method === "thread/resume") {
|
|
149
|
+
assert.equal(goal.status, "paused");
|
|
150
|
+
assert.equal(params.config["model_providers.deepseek"].testRevision, 2, "Credentials are prepared again at the turn boundary");
|
|
151
|
+
assert.equal(params.config.model_reasoning_effort, "low");
|
|
152
|
+
status = "idle"; modelProvider = params.modelProvider;
|
|
153
|
+
return { modelProvider };
|
|
154
|
+
}
|
|
155
|
+
if (method === "thread/settings/update") {
|
|
156
|
+
publishSettings = () => emit({ method: "thread/settings/updated", params: {
|
|
157
|
+
threadId: "native", threadSettings: { model: params.model, effort: params.effort }
|
|
158
|
+
} });
|
|
159
|
+
submitted.resolve();
|
|
160
|
+
return {};
|
|
161
|
+
}
|
|
162
|
+
assert.fail(`Unexpected native request ${method}`);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const goalEvents = [];
|
|
166
|
+
provider.subscribe(event => { if (event.method === "thread/goal/updated") goalEvents.push(event.params.goal.status); });
|
|
167
|
+
assert.deepEqual(await provider.updateThreadSettings("native", { model: "deepseek-model", effort: "low" }), { ok: true, pending: true });
|
|
168
|
+
const applying = provider.threadEnvironmentTasks.get("native");
|
|
169
|
+
assert.equal((await provider.readGoal("native")).goal.status, "active");
|
|
170
|
+
assert.deepEqual(goalEvents, ["active"], "Internal scheduling holds never expose a false Resume action");
|
|
171
|
+
if (pause) {
|
|
172
|
+
await provider.setGoalStatus("native", "paused");
|
|
173
|
+
assert.equal((await provider.readGoal("native")).goal.status, "paused");
|
|
174
|
+
}
|
|
175
|
+
credentialsRevision = 2;
|
|
176
|
+
status = "idle";
|
|
177
|
+
await submitted.promise;
|
|
178
|
+
assert.equal(goal.status, "paused", "Queued settings do not release automatic scheduling before application");
|
|
179
|
+
publishSettings();
|
|
180
|
+
await applying;
|
|
181
|
+
assert.equal(modelProvider, "deepseek");
|
|
182
|
+
assert.equal(goal.status, pause ? "paused" : "active");
|
|
183
|
+
assert.equal(goal.createdAt, 1);
|
|
184
|
+
assert.equal(goal.tokenBudget, 100);
|
|
185
|
+
assert.equal(goal.tokensUsed, 40);
|
|
186
|
+
assert.deepEqual(prepared, [1, 2]);
|
|
187
|
+
assert.equal(requests.some(row => row.method === "turn/interrupt" || row.method === "turn/start"), false);
|
|
188
|
+
});
|
|
189
|
+
}
|