@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.
Files changed (29) hide show
  1. package/examples/conversation/package.json +1 -1
  2. package/fixtures/responsive-assistant/DeliveryFixture.vue +3 -2
  3. package/package.json +1 -1
  4. package/src/client/conversation/AssistantGoalControl.vue +8 -8
  5. package/src/client/conversation/AssistantTranscript.vue +6 -4
  6. package/src/server/conversation/agentRun.js +12 -0
  7. package/src/server/conversation/claudeHistory.js +9 -3
  8. package/src/server/conversation/claudeProcess.js +60 -20
  9. package/src/server/conversation/claudeTurn.js +190 -41
  10. package/src/server/conversation/codexProvider.js +67 -3
  11. package/src/server/conversation/index.js +2 -2
  12. package/src/server/conversation/openCodeRuntime.js +8 -4
  13. package/src/server/conversation/providers/claude.js +5 -1
  14. package/src/server/conversation/providers/claudeDriver.js +5 -1
  15. package/src/server/conversation/providers/codex.js +325 -216
  16. package/src/server/conversation/providers/codexDriver.js +14 -1
  17. package/src/server/conversation/providers/scoped.js +39 -23
  18. package/src/server/conversation/runtime.js +20 -1
  19. package/src/server/conversation/runtimeStateUpgrade.js +42 -0
  20. package/test/claudeConversationSession.test.js +3 -2
  21. package/test/claudeProcess.test.js +139 -1
  22. package/test/claudeTurn.test.js +253 -3
  23. package/test/codexConversationSession.test.js +27 -4
  24. package/test/codexGoal.test.js +152 -0
  25. package/test/conversationRuntime.test.js +168 -4
  26. package/test/conversationRuntimeStateUpgrade.test.js +69 -1
  27. package/test/conversationSession.test.js +48 -0
  28. package/test/messageDelivery.browser.test.js +15 -0
  29. package/test/openCodeRuntime.test.js +71 -1
@@ -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 carry the exact live output identity before native block retirement", async () => {
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: false }, cancel: { delivery: "control", interruptsTurn: false }
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, "working");
739
- assert.equal((await f.trace()).some(row => row.method === "turn/interrupt"), false);
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);
@@ -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
+ }