@jskit-ai/assistant-core 0.2.1 → 0.2.3

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 (61) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
  3. package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
  4. package/locales/en.json +4 -2
  5. package/package.json +5 -5
  6. package/src/client/conversation/AssistantConversationElement.vue +8 -1
  7. package/src/client/conversation/AssistantGoalControl.vue +2 -2
  8. package/src/client/conversation/AssistantTranscript.vue +41 -11
  9. package/src/client/conversation/messageDelivery.js +23 -6
  10. package/src/client/lib/assistantApi.js +5 -0
  11. package/src/server/conversation/claudeProcess.js +171 -53
  12. package/src/server/conversation/claudeTurn.js +199 -63
  13. package/src/server/conversation/codexAuthPreflight.js +3 -0
  14. package/src/server/conversation/codexConfiguration.js +42 -4
  15. package/src/server/conversation/codexConversations.js +256 -114
  16. package/src/server/conversation/codexEvents.js +16 -3
  17. package/src/server/conversation/codexHelperThreadRecovery.js +17 -4
  18. package/src/server/conversation/codexHelperThreads.js +10 -1
  19. package/src/server/conversation/codexHomeStorage.js +69 -0
  20. package/src/server/conversation/codexProcess.js +7 -2
  21. package/src/server/conversation/codexProvider.js +57 -46
  22. package/src/server/conversation/codexProviderOwner.js +272 -10
  23. package/src/server/conversation/codexProviderSelection.js +13 -3
  24. package/src/server/conversation/codexRuntimeLifecycle.js +56 -4
  25. package/src/server/conversation/codexSessionCleanup.js +67 -20
  26. package/src/server/conversation/codexSessionReconciliation.js +18 -4
  27. package/src/server/conversation/codexTurn.js +9 -2
  28. package/src/server/conversation/continuity.js +34 -14
  29. package/src/server/conversation/openCodeClient.js +34 -0
  30. package/src/server/conversation/openCodeProcess.js +37 -5
  31. package/src/server/conversation/openCodeRuntime.js +257 -69
  32. package/src/server/conversation/providers/claudeDriver.js +7 -5
  33. package/src/server/conversation/providers/codexDriver.js +23 -16
  34. package/src/server/conversation/providers/opencodeDriver.js +17 -14
  35. package/src/server/conversation/providers/scoped.js +5 -2
  36. package/src/server/conversation/runtime.js +93 -6
  37. package/src/server/hosts/codexConfiguration.js +4 -0
  38. package/test/assistantScroll.browser.test.js +134 -18
  39. package/test/claudeConversationSession.test.js +26 -1
  40. package/test/claudeLifecycle.test.js +293 -0
  41. package/test/claudeProcess.test.js +364 -33
  42. package/test/claudeTurn.test.js +192 -3
  43. package/test/codexAccountChange.test.js +261 -0
  44. package/test/codexConversationsCheckpoint.test.js +3 -0
  45. package/test/codexEvents.test.js +23 -1
  46. package/test/codexHomeStorage.test.js +57 -0
  47. package/test/codexInteractiveArguments.test.js +9 -0
  48. package/test/codexLifecycleIsolation.test.js +441 -0
  49. package/test/codexModelCatalog.test.js +48 -2
  50. package/test/codexProcess.test.js +21 -0
  51. package/test/codexTemporaryProviderRelease.test.js +180 -0
  52. package/test/codexTurn.test.js +52 -1
  53. package/test/conversationNativeStorage.test.js +284 -0
  54. package/test/conversationRuntime.test.js +66 -0
  55. package/test/conversationSession.test.js +4 -8
  56. package/test/messageDelivery.browser.test.js +18 -2
  57. package/test/messageDelivery.test.js +34 -1
  58. package/test/openCodeClient.test.js +31 -0
  59. package/test/openCodeConnectionChange.test.js +166 -0
  60. package/test/openCodeProcess.test.js +36 -0
  61. package/test/openCodeRuntime.test.js +25 -78
@@ -579,6 +579,16 @@ test("a native error result is failure information rather than an assistant answ
579
579
  assert.equal(events.some(event => event.type === "message"), false);
580
580
  });
581
581
 
582
+ test("a turn whose account signed out ends at once with the not-connected message", async () => {
583
+ const { turn, send } = fixture();
584
+ await send();
585
+ // Claude Code's own answer when its credentials were removed under it.
586
+ await turn.receive({ type: "result", subtype: "success", is_error: true, result: "Not logged in · Please run /login" });
587
+ assert.equal(turn.read().active, false);
588
+ assert.equal(turn.read().status, "failed");
589
+ assert.equal(turn.read().error, "Claude isn't connected. Sign in to Claude, then send your message again.");
590
+ });
591
+
582
592
  function renewalFixture() {
583
593
  const input = { clientMessageId: "renewal-operation", prompt: "Approved prompt", timeoutMs: 100 };
584
594
  const uuid = claudeNativeMessageId(input.clientMessageId);
@@ -894,6 +904,24 @@ async function publishedClaudeFixture(t, { publication = true, providerId = "ant
894
904
  async reopen() { entry = await owner.open(context, { mainId: conversationId }); return entry; } };
895
905
  }
896
906
 
907
+ test("Claude proves a prompt was not admitted only once no process can still record it", async t => {
908
+ const f = await publishedClaudeFixture(t);
909
+ const { inspectPersistentConversationAdmission } = await import("../src/server/conversation/providers/scoped.js");
910
+ // Temporary chats inspect the same proof through their native read.
911
+ const persistent = () => inspectPersistentConversationAdmission({ sessionId: "session", context: {},
912
+ record: { providerConversationId: f.conversationId }, input: { messageId: "held" }, inputFor: (_record, input) => input,
913
+ operations: { readConversation: (_sessionId, input) => f.owner.read(f.entry, input) } });
914
+ await f.send("held");
915
+ // The live process received the prompt but has not recorded it yet.
916
+ assert.equal((await f.owner.inspectAdmission(f.entry, { messageId: "held" })).admission, "unknown");
917
+ assert.equal((await f.owner.read(f.entry, { messageId: "held" })).notAdmitted, false);
918
+ assert.equal((await persistent()).admission, "unknown");
919
+ await f.owner.stopConversation(f.entry, "Stopped.");
920
+ assert.equal((await f.owner.inspectAdmission(f.entry, { messageId: "held" })).admission, "not-admitted");
921
+ assert.equal((await f.owner.read(f.entry, { messageId: "held" })).notAdmitted, true);
922
+ assert.equal((await persistent()).admission, "not-admitted");
923
+ });
924
+
897
925
  test("Claude status retains active instructions and applies changed guidance once idle", async t => {
898
926
  const f = await publishedClaudeFixture(t);
899
927
  await f.send();
@@ -908,15 +936,50 @@ test("Claude status retains active instructions and applies changed guidance onc
908
936
  assert.equal(f.processes.length, 2, "Idle readiness installs the changed instructions");
909
937
  });
910
938
 
911
- test("Claude active status still rejects account replacement", async t => {
939
+ test("Claude active status keeps a running turn across an account change; idle admission applies it", async t => {
912
940
  const f = await publishedClaudeFixture(t);
913
941
  await f.send();
942
+ const running = f.entry.process;
914
943
  f.behavior.account = "account-b";
944
+ const state = await f.owner.ensureReady(f.entry);
945
+ assert.equal(state.turn.active, true);
946
+ assert.equal(f.entry.process, running, "the running turn keeps its process");
947
+ await f.frame({ type: "result", subtype: "success", result: "Done" });
948
+ // Once idle, the existing account guard applies.
915
949
  await assert.rejects(f.owner.ensureReady(f.entry), { code: "claude_account_changed" });
916
950
  assert.equal(f.entry.process, null);
917
951
  });
918
952
 
919
- test("Claude account change stops running processes without reopening saved conversations", async t => {
953
+ test("Claude account change stops and waits on nothing; a running turn keeps its process", async t => {
954
+ const f = await publishedClaudeFixture(t);
955
+ f.context.sessionId = "session-a";
956
+ await f.send();
957
+ const running = f.entry.process;
958
+ const touched = [];
959
+ const application = {
960
+ readContext: async () => { touched.push("read-context"); throw new Error("A saved conversation was reopened"); },
961
+ terminals: { close: async sessionId => { touched.push(`close-terminals:${sessionId}`); return { ok: true, closed: 0 }; } }
962
+ };
963
+ // Cancelled account reads are still settling; the change must not wait for them.
964
+ const reads = Promise.withResolvers();
965
+ let invalidated = 0;
966
+ let returned = false;
967
+ const change = f.owner.invalidateRuntimes({ provider: "claude", reason: "claude-auth-change" }, application,
968
+ { queries: { invalidate: () => { invalidated++; return reads.promise; } }, failure: error => { throw error; } });
969
+ void change.then(() => { returned = true; });
970
+ await new Promise(resolve => setImmediate(resolve));
971
+ assert.equal(returned, true, "the change returned without waiting for the cancelled account reads");
972
+ reads.resolve();
973
+ assert.deepEqual(await change, { ok: true, closed: 0 });
974
+ assert.equal(invalidated, 1, "the account's cached reads were dropped");
975
+ assert.deepEqual(touched, [], "no terminal closed and no saved conversation reopened");
976
+ assert.equal(f.entry.process, running, "the running process was not stopped");
977
+ assert.equal(f.entry.turn.active, true, "the running turn continues");
978
+ await f.frame({ type: "result", subtype: "success", result: "Done" });
979
+ assert.equal(f.entry.turn.state, "completed", "the turn finishes normally");
980
+ });
981
+
982
+ test("Claude server shutdown stops running processes without reopening saved conversations", async t => {
920
983
  const f = await publishedClaudeFixture(t);
921
984
  f.context.sessionId = "session-a";
922
985
  await f.send();
@@ -926,7 +989,7 @@ test("Claude account change stops running processes without reopening saved conv
926
989
  readContext: async () => { throw new Error("A saved conversation was reopened"); },
927
990
  terminals: { close: async sessionId => { closedTerminals.push(sessionId); return { ok: true, closed: 0 }; } }
928
991
  };
929
- const result = await f.owner.invalidateRuntimes({ provider: "claude", reason: "claude-auth-change" }, application,
992
+ const result = await f.owner.invalidateRuntimes({ reason: "server-shutdown" }, application,
930
993
  { queries: { invalidate: async () => {} }, failure: error => { throw error; } });
931
994
  assert.equal(result.ok, true);
932
995
  assert.equal(f.entry.process, null, "the running process stopped");
@@ -1853,6 +1916,97 @@ test("consecutive steers queue together without waiting for or sending an interr
1853
1916
  assert.deepEqual(events.filter(event => event.type === "diagnostic" && event.stage === "steering_queued").length, 2);
1854
1917
  });
1855
1918
 
1919
+ // Claude Code reads a queued message only when the running tool call returns. A
1920
+ // foreground wait (`until …; do sleep 20; done`) held a person's question for ten
1921
+ // minutes, so a waiting steer moves foreground work to the background instead.
1922
+ function steeringFixture() {
1923
+ const f = fixture();
1924
+ const requests = [];
1925
+ f.client.interrupt = async () => assert.fail("Steering never interrupts");
1926
+ f.client.request = async request => { requests.push(request); return {}; };
1927
+ // Claude Code acknowledges streamed input as queued; the replay comes when it reads it.
1928
+ f.client.send = async (_message, { messageId }) => {
1929
+ await f.turn.receive({ type: "command_lifecycle", command_uuid: messageId, state: "queued" });
1930
+ };
1931
+ async function read(id) {
1932
+ const uuid = claudeNativeMessageId(id);
1933
+ await f.turn.receive({ type: "command_lifecycle", command_uuid: uuid, state: "started" });
1934
+ await f.turn.receive({ type: "user", uuid });
1935
+ }
1936
+ return { ...f, requests, read };
1937
+ }
1938
+
1939
+ test("a steer queued behind a running command asks Claude to background it, never to interrupt it", async () => {
1940
+ const { turn, send, read, requests, events } = steeringFixture();
1941
+ await send();
1942
+ await read("first");
1943
+ await turn.receive({ type: "assistant", uuid: "wait-call", message: { id: "wait-api", stop_reason: "tool_use",
1944
+ content: [{ type: "tool_use", id: "wait", name: "Bash", input: { command: "until grep -q done out; do sleep 20; done" } }] } });
1945
+ await turn.receive({ type: "system", subtype: "task_started", task_id: "wait-task" });
1946
+ assert.deepEqual(requests, [], "A first message and its commands stay in the foreground");
1947
+ await turn.prepareSteering();
1948
+ await send("question", { steer: true });
1949
+ await delay(0);
1950
+ assert.deepEqual(requests, [{ subtype: "background_tasks" }]);
1951
+ assert.equal(events.filter(event => event.type === "diagnostic" && event.stage === "steering_backgrounded").length, 1);
1952
+ await turn.receive({ type: "user", uuid: "wait-result", message: { content: [{ type: "tool_result", tool_use_id: "wait",
1953
+ content: "Command was manually backgrounded by user with ID: wait-task." }] } });
1954
+ await read("question");
1955
+ await turn.receive({ type: "system", subtype: "task_started", task_id: "later-task" });
1956
+ await delay(0);
1957
+ assert.equal(requests.length, 1, "A message Claude has read no longer moves work");
1958
+ turn.stop();
1959
+ });
1960
+
1961
+ test("a steer still waiting when Claude starts another command backgrounds that command too", async () => {
1962
+ const { turn, send, read, requests } = steeringFixture();
1963
+ await send();
1964
+ await read("first");
1965
+ // The person writes while Claude is choosing its next tool: nothing runs yet.
1966
+ await turn.prepareSteering();
1967
+ await send("question", { steer: true });
1968
+ await turn.receive({ type: "system", subtype: "task_started", task_id: "new-wait" });
1969
+ await delay(0);
1970
+ assert.deepEqual(requests, [{ subtype: "background_tasks" }, { subtype: "background_tasks" }]);
1971
+ await read("question");
1972
+ turn.stop();
1973
+ });
1974
+
1975
+ test("a steer during Claude's follow-up to a finished background task moves that follow-up's command", async () => {
1976
+ const { turn, send, read, requests } = steeringFixture();
1977
+ await send();
1978
+ await read("first");
1979
+ await turn.receive({ type: "system", subtype: "task_started", task_id: "verify" });
1980
+ await turn.receive({ type: "result", subtype: "success", result: "Verification runs in the background." });
1981
+ await turn.receive({ type: "command_lifecycle", command_uuid: claudeNativeMessageId("first"), state: "completed" });
1982
+ assert.deepEqual([turn.read().phase, turn.read().pendingCommands], ["background", 0]);
1983
+ // Claude reads the task notification and starts waiting in the foreground.
1984
+ await turn.receive({ type: "system", subtype: "task_notification", task_id: "verify", status: "completed" });
1985
+ await turn.receive({ type: "system", subtype: "task_started", task_id: "follow-up-wait" });
1986
+ assert.deepEqual(requests, []);
1987
+ await turn.prepareSteering();
1988
+ await send("question", { steer: true });
1989
+ await delay(0);
1990
+ assert.deepEqual(requests, [{ subtype: "background_tasks" }]);
1991
+ await read("question");
1992
+ turn.stop();
1993
+ });
1994
+
1995
+ test("a refused background request leaves the steer delivered and reports it", async () => {
1996
+ const { turn, send, read, requests, client, events } = steeringFixture();
1997
+ await send();
1998
+ await read("first");
1999
+ client.request = async request => { requests.push(request); throw new Error("Unsupported control request: background_tasks"); };
2000
+ await turn.prepareSteering();
2001
+ await send("question", { steer: true });
2002
+ await delay(0);
2003
+ assert.deepEqual(requests, [{ subtype: "background_tasks" }]);
2004
+ assert.equal(turn.read().pendingCommands, 2, "The steer stays delivered and queued for Claude");
2005
+ const failed = events.find(event => event.type === "diagnostic" && event.stage === "steering_background_failed");
2006
+ assert.equal(failed?.error, "Unsupported control request: background_tasks");
2007
+ turn.stop();
2008
+ });
2009
+
1856
2010
  test("folded internal command preserves the original published answer owner", async t => {
1857
2011
  const f = await publishedClaudeFixture(t);
1858
2012
  await f.send();
@@ -2287,6 +2441,41 @@ for (const action of ["steer", "set", "resume"]) test(`Claude ${action} queues a
2287
2441
  assert.equal(f.entry.nativeTurn.read().active, false, "The main response settles the completed worker's turn");
2288
2442
  });
2289
2443
 
2444
+ for (const action of ["steer", "set", "resume"]) test(`Claude ${action} moves a running command to the background so Claude reads it at once`, async t => {
2445
+ const f = await publishedClaudeFixture(t);
2446
+ if (action === "resume") await f.owner.updateGoal(f.entry, { action: "set", objective: "Finish checks", messageId: "first" });
2447
+ else await f.send("first");
2448
+ await f.frame({ type: "assistant", uuid: "wait-call", message: { id: "wait-api", stop_reason: "tool_use",
2449
+ content: [{ type: "tool_use", id: "wait", name: "Bash", input: { command: "until grep -q done out; do sleep 20; done" } }] } });
2450
+ const native = f.entry.process;
2451
+ const requests = [];
2452
+ native.client.request = async request => { requests.push(request); return {}; };
2453
+ native.client.interrupt = async () => assert.fail("A steer must not send Stop");
2454
+ const send = native.client.send;
2455
+ const queued = Promise.withResolvers();
2456
+ // Claude Code 2.1.308 acknowledges the input as queued while the wait loop runs.
2457
+ native.client.send = async (message, input) => {
2458
+ await f.frame({ type: "command_lifecycle", command_uuid: input.messageId, state: "queued" });
2459
+ queued.resolve({ message, input });
2460
+ };
2461
+ const goal = action === "steer" ? null : (await f.owner.readGoal(f.entry)).goal;
2462
+ const delivering = action === "steer" ? f.send("next") : f.owner.updateGoal(f.entry, { action, objective: "Finish checks",
2463
+ messageId: "next", ...(action === "resume" ? { threadId: f.conversationId, createdAt: goal.createdAt } : {}) });
2464
+ const { message, input } = await queued.promise;
2465
+ await delay(0);
2466
+ assert.deepEqual(requests, [{ subtype: "background_tasks" }]);
2467
+ assert.equal(f.processes.length, 1, "The running process and its command are kept");
2468
+ // The wait loop returns as backgrounded; Claude then reads the queued input.
2469
+ await f.frame({ type: "user", uuid: "wait-result", message: { content: [{ type: "tool_result", tool_use_id: "wait",
2470
+ content: "Command was manually backgrounded by user with ID: wait-task." }] } });
2471
+ await f.frame({ type: "command_lifecycle", command_uuid: input.messageId, state: "started" });
2472
+ await send(message, input);
2473
+ await delivering;
2474
+ await f.frame({ type: "system", subtype: "task_started", task_id: "wait-task" });
2475
+ await delay(0);
2476
+ assert.equal(requests.length, 1, "Once Claude reads the input, later commands stay in the foreground");
2477
+ });
2478
+
2290
2479
  test("Claude context inspection reuses the native owner and reports the selected model's current usage", async t => {
2291
2480
  const f = await publishedClaudeFixture(t);
2292
2481
  const requests = [];
@@ -0,0 +1,261 @@
1
+ import assert from "node:assert/strict";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+ import { CodexAppServerAgentProvider, ensureCodexAppServerThread } from "../src/server/conversation/codexProvider.js";
8
+ import { writeCodexAppServerMetadata } from "../src/server/conversation/codexProcess.js";
9
+ import { createCodexAppServerProviderOwner, codexAppServerOwnedRuntimeKey, releaseSupersededCodexRuntime } from "../src/server/conversation/codexProviderOwner.js";
10
+ import { createCodexProviderSelection } from "../src/server/conversation/codexProviderSelection.js";
11
+ import { createCodexRuntimeLifecycle } from "../src/server/conversation/codexRuntimeLifecycle.js";
12
+
13
+ // A native runtime whose loaded threads report the given status. Codex reads
14
+ // its credentials once, so the account change must leave a running turn alone.
15
+ function nativeRuntime({ threads, goals = {} }) {
16
+ const requests = [];
17
+ const provider = {
18
+ stale: false,
19
+ stopped: 0,
20
+ closed: 0,
21
+ client: {
22
+ isOpen: () => true,
23
+ async request(method, params) {
24
+ requests.push(method === "thread/goal/set" ? `${method}:${params.status}` : method);
25
+ if (method === "thread/loaded/list") return { data: Object.keys(threads) };
26
+ if (method === "thread/read") return { thread: { id: params.threadId, status: { type: threads[params.threadId] } } };
27
+ if (method === "thread/goal/get") return { goal: goals[params.threadId] || null };
28
+ if (method === "thread/goal/set") {
29
+ goals[params.threadId] = { ...goals[params.threadId], status: params.status };
30
+ return { goal: goals[params.threadId] };
31
+ }
32
+ throw new Error(`Unexpected ${method}`);
33
+ }
34
+ },
35
+ markStale() { this.stale = true; },
36
+ stopOptions: [],
37
+ async stopRuntime(options) { this.stopped += 1; this.stopOptions.push(options); return { stopped: true, processExitVerified: true }; },
38
+ close() { this.closed += 1; }
39
+ };
40
+ return { provider, requests, threads, goals };
41
+ }
42
+
43
+ function ownerWith(t, runtimes, options = {}) {
44
+ const root = path.join(os.tmpdir(), `jskit-codex-account-change-${randomUUID()}`);
45
+ const owner = createCodexAppServerProviderOwner({ runtimeRoot: root, ...options });
46
+ const keys = {};
47
+ for (const [name, runtime] of Object.entries(runtimes)) {
48
+ const providerOptions = { runtimeDir: path.join(root, name) };
49
+ owner.createProvider({ providerKey: name, providerOptions, preserveProcessExitProof: true, create: () => runtime.provider });
50
+ owner.rememberOwnedRuntime({ provider: runtime.provider, providerKey: name, providerOptions });
51
+ keys[name] = codexAppServerOwnedRuntimeKey(name, providerOptions);
52
+ }
53
+ t.after(() => owner.beginShutdown());
54
+ return { owner, keys, root };
55
+ }
56
+
57
+ test("an account change stops an idle Codex runtime at once and leaves a running turn on its runtime", async t => {
58
+ const idle = nativeRuntime({ threads: { "idle-thread": "idle" } });
59
+ const busy = nativeRuntime({ threads: { "busy-thread": "active" }, goals: { "busy-thread": { status: "active" } } });
60
+ const { owner, keys } = ownerWith(t, { idle, busy });
61
+
62
+ const result = await owner.invalidateRuntimes({
63
+ deferBusy: true, includeOwned: true, requireVerifiedExit: true, stopOwnedRuntimes: true
64
+ });
65
+
66
+ assert.equal(result.ok, true);
67
+ assert.equal(idle.provider.stopped, 1, "An idle runtime stops at once");
68
+ assert.equal(busy.provider.stopped, 0, "A running turn is never stopped by an account change");
69
+ assert.equal(busy.provider.stale, true);
70
+ assert.deepEqual(result.deferred.map(({ runtimeKey, threadIds, pausedGoals }) => ({ runtimeKey, threadIds, pausedGoals })),
71
+ [{ runtimeKey: keys.busy, threadIds: ["busy-thread"], pausedGoals: ["busy-thread"] }]);
72
+ assert.ok(busy.requests.includes("thread/goal/set:paused"), "An active goal is paused so the old runtime starts no further turn");
73
+ assert.equal(owner.providers.get("busy"), busy.provider);
74
+
75
+ assert.equal((await owner.retireStaleRuntime(keys.busy)).busy, true, "It keeps running while its turn runs");
76
+ assert.equal(busy.provider.stopped, 0);
77
+
78
+ busy.threads["busy-thread"] = "idle";
79
+ const retired = await owner.retireStaleRuntime(keys.busy);
80
+ assert.equal(retired.stopped, true);
81
+ assert.equal(busy.provider.stopped, 1, "The stale runtime stops through the verified stop once idle");
82
+ assert.equal(owner.providers.get("busy"), undefined);
83
+ assert.equal(await owner.retireStaleRuntime(keys.busy), null);
84
+ });
85
+
86
+ test("a session's next Codex runtime starts only after its finished stale runtime stopped", async t => {
87
+ const old = nativeRuntime({ threads: { "thread-1": "active" } });
88
+ const current = { client: null, close() {} };
89
+ const { owner, root } = ownerWith(t, { old }, { providerFactory: () => current });
90
+ await owner.invalidateRuntimes({ deferBusy: true, includeOwned: true, requireVerifiedExit: true, stopOwnedRuntimes: true });
91
+ assert.equal(old.provider.stale, true);
92
+ old.threads["thread-1"] = "idle";
93
+
94
+ const provider = await owner.providerForSession({
95
+ providerKey: "current",
96
+ providerOptions: { runtimeDir: path.join(root, "current") },
97
+ assertAdmission() {},
98
+ keyFields: () => ({ sessionId: "s1", executionMode: "", executionRoot: "/work", runtimeInstanceId: "", workdir: "/work" }),
99
+ prepare: async () => ({ parameters: {}, runInContext: operation => operation(), controls: { available: false, subscribe: () => () => {} } })
100
+ });
101
+
102
+ assert.equal(provider, current);
103
+ assert.equal(old.provider.stopped, 1, "The previous account's runtime stops before the thread loads in the new one");
104
+ assert.equal(owner.providers.get("old"), undefined);
105
+ });
106
+
107
+ test("a stale Codex runtime is retired after its turn completes and its paused goal continues", async () => {
108
+ const subscribers = new Set();
109
+ const provider = { subscribe(callback) { subscribers.add(callback); return () => subscribers.delete(callback); } };
110
+ const stale = { runtimeKey: "runtime:/old", providerKeys: ["old-key"], threadIds: ["thread-1"], pausedGoals: ["thread-1"] };
111
+ const calls = [];
112
+ let busy = true;
113
+ const notificationTask = Promise.withResolvers();
114
+ const lifecycle = createCodexRuntimeLifecycle({
115
+ pendingWork: { notificationTasks: new Map([["turn", notificationTask.promise]]), resultFinalizations: new Map() },
116
+ observer: { eventSubscriptions: new Map() },
117
+ providerSessions: {
118
+ managed: new Map([["old-key", { sessionId: "session-1", threadId: "thread-1", projectContext: "project-a" }]]),
119
+ owner: {
120
+ providers: new Map([["old-key", provider]]),
121
+ async invalidateRuntimes(input) {
122
+ calls.push(`invalidate:${input.deferBusy}`);
123
+ return { deferred: [stale], failed: [], providerCount: 1, results: [], stopped: 0 };
124
+ },
125
+ async retireStaleRuntime(runtimeKey) {
126
+ calls.push(`retire:${runtimeKey}:${busy ? "busy" : "idle"}`);
127
+ return busy ? { ...stale, busy: true } : { ...stale, stopped: true };
128
+ }
129
+ }
130
+ },
131
+ helperLifecycle: {},
132
+ captureContext: () => null,
133
+ runInContext: (context, operation) => { calls.push(`context:${context}`); return operation(); },
134
+ continueGoalAfterAccountChange: async (sessionId, { threadId }) => calls.push(`continue:${sessionId}:${threadId}`),
135
+ debugLog() {},
136
+ debugError: error => error
137
+ });
138
+
139
+ await lifecycle.invalidateRuntimes({ includeOwned: true, reason: "codex-connected" }, { enabled: true });
140
+ assert.deepEqual(calls, ["invalidate:true"], "The account change returns without waiting for the running turn");
141
+
142
+ busy = false;
143
+ notificationTask.resolve();
144
+ for (const callback of subscribers) callback({ method: "turn/completed", params: { threadId: "thread-1" } });
145
+ await new Promise(resolve => setTimeout(resolve, 20));
146
+ assert.deepEqual(calls.slice(-3), ["retire:runtime:/old:idle", "context:project-a", "continue:session-1:thread-1"]);
147
+ assert.equal(subscribers.size, 0, "A retired runtime leaves no observer behind");
148
+ });
149
+
150
+ test("a stale Codex provider keeps its runtime and the current account through an auth generation change", async t => {
151
+ const stops = [];
152
+ const marked = [];
153
+ const provider = new CodexAppServerAgentProvider({ runtimeDir: "/tmp/old" }, {
154
+ credentials: {
155
+ async assertCurrent() { throw Object.assign(new Error("Codex authentication changed."), { code: "auth_generation_changed" }); },
156
+ async markInvalid(input) { marked.push(input); },
157
+ reconnectError: () => new Error("reconnect")
158
+ },
159
+ runtime: {
160
+ async ensure() { throw new Error("A stale provider must not restart its runtime"); },
161
+ async stop() { stops.push("stop"); return { processExitVerified: true }; }
162
+ },
163
+ parameters: {}
164
+ });
165
+ t.after(() => provider.close());
166
+ provider.runtime = { runtimeDir: "/tmp/old", authStateSignature: "v1:old" };
167
+ let rejected = false;
168
+ provider.client = { isOpen: () => true, close() {}, async request() {
169
+ if (rejected) throw new Error("unexpected status 401 Unauthorized: Incorrect API key provided");
170
+ return { turn: { id: "turn-1" } };
171
+ } };
172
+ provider.markStale();
173
+
174
+ assert.equal((await provider.steerTurn("thread-1", "turn-1", "more")).id, "turn-1");
175
+ assert.deepEqual(stops, [], "A steer on the running turn keeps its runtime after the account changed");
176
+ rejected = true;
177
+ await assert.rejects(provider.steerTurn("thread-1", "turn-1", "more"), /401/u);
178
+ assert.deepEqual(stops, [], "The running turn's runtime is not stopped");
179
+ assert.deepEqual(marked, [], "The previous account's rejection never marks the current account");
180
+
181
+ provider.client = null;
182
+ await assert.rejects(provider.ensureAvailable(), error => error.code === "codex_account_changed");
183
+ assert.deepEqual(stops, []);
184
+ });
185
+
186
+ test("a stale Codex provider serves its running turn and not the session's next message", async () => {
187
+ const stale = { stale: true, isAvailable: () => true };
188
+ const ensured = [];
189
+ let active = true;
190
+ const selection = createCodexProviderSelection({
191
+ providerSessions: {
192
+ managed: new Map([["old-key", { sessionId: "s1", threadId: "thread-1", workdir: "/work", providerOptions: { old: true } }]]),
193
+ keyFields: () => ({ sessionId: "s1", executionRoot: "/work", workdir: "/work" }),
194
+ owner: {
195
+ providers: new Map([["old-key", stale]]),
196
+ async ensureSession(connection) { ensured.push(connection.providerOptions); return stale; }
197
+ },
198
+ context: (sessionId, providerOptions) => ({ sessionId, providerOptions, providerKey: "old-key" })
199
+ },
200
+ turnState: () => ({ active, threadId: "thread-1" }),
201
+ helperLifecycle: null,
202
+ helperOwnershipError: null,
203
+ modelCatalogCacheMs: 1,
204
+ errorPrefix: ""
205
+ });
206
+ const session = { sessionId: "s1" };
207
+
208
+ assert.equal((await selection.ensureCodexAppServerProviderForActiveTurn(session, { workdir: "/work" }))?.provider, stale);
209
+ assert.equal(selection.managedProviderKeyForThread("s1", "/work", "thread-1", { turnActive: true }), "old-key");
210
+ active = false;
211
+ const context = selection.codexAppServerMessageContext("s1", {
212
+ managedIdentity: () => ({ workdir: "/work" }),
213
+ providerOptions: async () => ({ current: true })
214
+ });
215
+ assert.equal((await context.acquireProvider(session)).reused, false, "A new message goes to the current account's provider");
216
+ assert.deepEqual(ensured.at(-1), { current: true });
217
+ assert.equal(selection.managedProviderKeyForThread("s1", "/work", "thread-1"), null);
218
+ });
219
+
220
+ async function runtimeDirectory(root, name, processState) {
221
+ const runtimeDir = path.join(root, `codex-app-server-${name}`);
222
+ await mkdir(runtimeDir, { recursive: true });
223
+ await writeCodexAppServerMetadata(runtimeDir, {
224
+ runtimeDir, processState, pid: 4242,
225
+ ...(processState === "stopped" ? { processExitVerifiedAt: new Date().toISOString() } : {})
226
+ });
227
+ return runtimeDir;
228
+ }
229
+ const exists = target => stat(target).then(() => true, () => false);
230
+
231
+ test("recording a session's runtime for a new sign-in removes the previous sign-in's stopped runtime directory", async t => {
232
+ const root = await mkdtemp(path.join(os.tmpdir(), "jskit-codex-superseded-"));
233
+ t.after(() => rm(root, { recursive: true, force: true }));
234
+ const stopped = await runtimeDirectory(root, "stopped", "stopped");
235
+ const running = await runtimeDirectory(root, "running", "running");
236
+ const bind = superseded => ensureCodexAppServerThread({
237
+ observeThread: async () => {},
238
+ provider: { ensureRuntime: async () => ({ runtimeDir: path.join(root, "codex-app-server-current") }),
239
+ startThread: async () => ({ id: "thread-1" }) },
240
+ settings: async () => ({ threadSettings: {}, threadStartSettings: {} }),
241
+ identity: { read: () => "", write: async () => ({ supersededRuntimeDir: superseded }) },
242
+ workdir: "/work"
243
+ });
244
+
245
+ await bind(running);
246
+ assert.equal(await exists(running), true, "A runtime that has not verifiably stopped keeps its directory");
247
+ await bind(stopped);
248
+ assert.equal(await exists(stopped), false, "The previous sign-in's stopped runtime directory goes");
249
+ });
250
+
251
+ test("a stale Codex runtime its session no longer records keeps no exit proof when it stops", async t => {
252
+ const busy = nativeRuntime({ threads: { "thread-1": "active" } });
253
+ const { owner, keys, root } = ownerWith(t, { busy });
254
+ await owner.invalidateRuntimes({ deferBusy: true, includeOwned: true, requireVerifiedExit: true, stopOwnedRuntimes: true });
255
+
256
+ assert.deepEqual(await releaseSupersededCodexRuntime(path.join(root, "busy")), { deferred: true, removed: false });
257
+ busy.threads["thread-1"] = "idle";
258
+ assert.equal((await owner.retireStaleRuntime(keys.busy)).stopped, true);
259
+ assert.deepEqual(busy.provider.stopOptions, [{ preserveProcessExitProof: false }],
260
+ "Its verified stop removes the directory instead of keeping the proof");
261
+ });
@@ -22,6 +22,9 @@ function persistentConversationFixture() {
22
22
  async checkpoint(_sessionId, input) { checkpoints.push(input); },
23
23
  conversationPreparation: { async context() { return { result: { provider } }; } },
24
24
  namespace: (sessionId) => sessionId,
25
+ // The provider owner still holds the provider observing the conversation;
26
+ // a running turn holds it through the owner.
27
+ providerSessions: { owner: { providers: new Map([["provider", provider]]), holdProvider: () => () => {} } },
25
28
  turnOutcomes
26
29
  });
27
30
  const state = commands.observeCodexConversation("session", "thread", provider);
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { codexAppServerProviderThreadAssistantSegments } from "../src/server/conversation/codexEvents.js";
3
+ import { codexAppServerNotificationError, codexAppServerProviderThreadAssistantSegments, codexAppServerProviderTurnError } from "../src/server/conversation/codexEvents.js";
4
4
 
5
5
  test("phase-less Codex progress before tools does not become a final answer", () => {
6
6
  const items = [
@@ -38,3 +38,25 @@ test("explicit Codex finals remain independent across goal continuation and hook
38
38
  { itemId: "answer-2", text: "Second result." }
39
39
  ]);
40
40
  });
41
+
42
+ // A running app-server keeps the credentials it started with (Codex 0.163,
43
+ // local mock): once they are rejected after a sign-out or account switch, its
44
+ // turns end with these native errors, which say nothing about reconnecting.
45
+ test("a Codex turn whose OpenAI sign-in is gone asks the person to sign in again", () => {
46
+ const signedOut = "Codex (OpenAI) isn't connected. Sign in to Codex, then send your message again.";
47
+ for (const message of [
48
+ "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.",
49
+ "unexpected status 401 Unauthorized: Incorrect API key provided, url: https://api.openai.com/v1/responses",
50
+ "unexpected status 401 Unauthorized: unauthorized, url: https://chatgpt.com/backend-api/codex/responses"
51
+ ]) {
52
+ assert.equal(codexAppServerNotificationError({ method: "error", params: { error: { message } } }), signedOut);
53
+ assert.equal(codexAppServerNotificationError({ method: "turn/completed",
54
+ params: { turn: { status: "failed", error: { message } } } }), signedOut);
55
+ assert.equal(codexAppServerProviderTurnError({ status: "failed", error: { message } }), signedOut);
56
+ }
57
+ // Another provider's rejected key and other failures keep their own text.
58
+ for (const message of ["unexpected status 401 Unauthorized: invalid key, url: https://api.deepseek.com/v1/responses",
59
+ "stream disconnected before completion: stream closed before response.completed"]) {
60
+ assert.equal(codexAppServerNotificationError({ method: "error", params: { error: { message } } }), message);
61
+ }
62
+ });
@@ -0,0 +1,57 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { test } from "node:test";
6
+ import {
7
+ codexDisabledProcessFeatureArguments,
8
+ codexHomeDisposablePaths
9
+ } from "../src/server/hosts/codexConfiguration.js";
10
+
11
+ async function home(t, entries) {
12
+ const root = await mkdtemp(path.join(tmpdir(), "jskit-codex-home-"));
13
+ t.after(() => rm(root, { recursive: true, force: true }));
14
+ const codexHome = path.join(root, ".codex");
15
+ for (const entry of entries) {
16
+ const target = path.join(codexHome, entry);
17
+ if (entry.endsWith("/")) await mkdir(target, { recursive: true });
18
+ else { await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, "x"); }
19
+ }
20
+ return { root, codexHome };
21
+ }
22
+
23
+ const kept = ["auth.json", "config.toml", "models.json", "state_5.sqlite", "goals_1.sqlite", "memories_1.sqlite",
24
+ "queue_1.sqlite", "thread_history_1.sqlite", "sessions/2026/10/rollout.jsonl", "plugins/cache/installed/",
25
+ ".tmp/marketplaces/", ".tmp/rollout-maintenance.lock", "cache/unrelated/"];
26
+ const pluginAndApp = [".tmp/plugins/", ".tmp/plugins.sha", ".tmp/plugins.sync.lock", ".tmp/plugins-clone-a1/",
27
+ ".tmp/plugins-backup-b2/", ".tmp/git-c3/", ".tmp/bundled-marketplaces/", "cache/remote_plugin_catalog/",
28
+ "cache/codex_apps_tools/", "cache/codex_apps_server_info/", "cache/codex_app_directory/", "cache/bundled_plugin_exclusions/"];
29
+
30
+ test("Codex processes are launched with plugin and app background work and the shared background server off", () => {
31
+ assert.deepEqual(codexDisabledProcessFeatureArguments(), ["-c", "features.plugins=false", "-c", "features.apps=false",
32
+ "-c", "features.daemon_auto_start=false"]);
33
+ });
34
+
35
+ test("a used home loses only plugin and app caches; a retired home also loses its runtime-only data", async (t) => {
36
+ const { codexHome } = await home(t, [...kept, ...pluginAndApp, "logs_2.sqlite", "logs_2.sqlite-wal", "logs_2.sqlite-shm", "tmp/arg0/"]);
37
+ const strip = (entry) => path.join(codexHome, entry.replace(/\/$/u, ""));
38
+ assert.deepEqual(await codexHomeDisposablePaths(codexHome), pluginAndApp.map(strip).sort());
39
+ assert.deepEqual(await codexHomeDisposablePaths(codexHome, { retired: true }),
40
+ [".tmp", "cache", "logs_2.sqlite", "logs_2.sqlite-shm", "logs_2.sqlite-wal", "tmp"].map(strip).sort());
41
+ });
42
+
43
+ test("missing, linked and relative homes list nothing they do not own", async (t) => {
44
+ const { root, codexHome } = await home(t, ["cache/remote_plugin_catalog/"]);
45
+ assert.deepEqual(await codexHomeDisposablePaths(path.join(root, "missing")), []);
46
+ const linked = path.join(root, "linked");
47
+ await symlink(codexHome, linked);
48
+ assert.deepEqual(await codexHomeDisposablePaths(linked), []);
49
+ assert.deepEqual(await codexHomeDisposablePaths(linked, { retired: true }), []);
50
+ const elsewhere = path.join(root, "elsewhere");
51
+ await mkdir(path.join(elsewhere, ".tmp/plugins"), { recursive: true });
52
+ await rm(path.join(codexHome, "cache"), { recursive: true });
53
+ await symlink(path.join(elsewhere, ".tmp"), path.join(codexHome, ".tmp"));
54
+ assert.deepEqual(await codexHomeDisposablePaths(codexHome), [], "a linked .tmp is not followed");
55
+ assert.deepEqual(await codexHomeDisposablePaths(codexHome, { retired: true }), [path.join(codexHome, ".tmp")]);
56
+ await assert.rejects(codexHomeDisposablePaths(".codex"), /absolute Codex home/u);
57
+ });
@@ -18,6 +18,15 @@ test("remote Codex resume keeps server-owned permissions and the selected native
18
18
  ]);
19
19
  });
20
20
 
21
+ test("a local Codex terminal starts with plugin and app background work off and leaves no background server; a remote one uses its server's", () => {
22
+ const local = codexInteractiveArguments({ ...options, threadId: "local-thread" });
23
+ // The terminal's Codex would otherwise start a shared app-server daemon that outlives it.
24
+ assert.deepEqual(local.slice(0, 9), ["codex", "-c", "check_for_update_on_startup=false",
25
+ "-c", "features.plugins=false", "-c", "features.apps=false", "-c", "features.daemon_auto_start=false"]);
26
+ const remote = codexInteractiveArguments({ ...options, remoteEndpoint: "unix:///runtime/codex.sock" });
27
+ assert.equal(remote.some((value) => value.startsWith("features.")), false);
28
+ });
29
+
21
30
  test("local Codex and new remote threads retain their explicit permission selection", () => {
22
31
  for (const input of [{}, { threadId: "local-thread" }, { remoteEndpoint: "unix:///runtime/codex.sock" }]) {
23
32
  const args = codexInteractiveArguments({ ...options, ...input });