@jskit-ai/assistant-core 0.2.2 → 0.2.4

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 (59) 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 +49 -12
  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 +105 -58
  12. package/src/server/conversation/claudeTurn.js +179 -59
  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 +234 -29
  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 +13 -1
  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 +225 -64
  32. package/src/server/conversation/providers/claudeDriver.js +3 -3
  33. package/src/server/conversation/providers/codexDriver.js +10 -4
  34. package/src/server/conversation/providers/opencodeDriver.js +4 -2
  35. package/src/server/conversation/runtime.js +72 -2
  36. package/src/server/hosts/codexConfiguration.js +4 -0
  37. package/test/assistantScroll.browser.test.js +169 -10
  38. package/test/claudeLifecycle.test.js +293 -0
  39. package/test/claudeProcess.test.js +195 -51
  40. package/test/claudeTurn.test.js +174 -3
  41. package/test/codexAccountChange.test.js +261 -0
  42. package/test/codexConversationsCheckpoint.test.js +3 -0
  43. package/test/codexEvents.test.js +23 -1
  44. package/test/codexHomeStorage.test.js +57 -0
  45. package/test/codexInteractiveArguments.test.js +9 -0
  46. package/test/codexLifecycleIsolation.test.js +441 -0
  47. package/test/codexModelCatalog.test.js +48 -2
  48. package/test/codexProcess.test.js +21 -0
  49. package/test/codexTemporaryProviderRelease.test.js +180 -0
  50. package/test/codexTurn.test.js +21 -1
  51. package/test/conversationNativeStorage.test.js +57 -0
  52. package/test/conversationRuntime.test.js +66 -0
  53. package/test/conversationSession.test.js +4 -8
  54. package/test/messageDelivery.browser.test.js +18 -2
  55. package/test/messageDelivery.test.js +34 -1
  56. package/test/openCodeClient.test.js +31 -0
  57. package/test/openCodeConnectionChange.test.js +166 -0
  58. package/test/openCodeProcess.test.js +36 -0
  59. package/test/openCodeRuntime.test.js +25 -78
@@ -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 });