@jskit-ai/assistant-core 0.2.2 → 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 (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 +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 +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 +93 -0
  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
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
3
3
  import os, { tmpdir } from "node:os";
4
4
  import path, { join } from "node:path";
5
5
  import { test } from "node:test";
@@ -29,6 +29,9 @@ test("native command construction preserves tool-free constraints without granti
29
29
  assert.deepEqual(JSON.parse(coding[coding.indexOf("--mcp-config") + 1]), { mcpServers: { application: { type: "sdk", name: "application" } } });
30
30
  });
31
31
 
32
+ // Account queries whose credentials are not the subject read them from nowhere.
33
+ const ABSENT_CLAUDE_CONFIG = join(tmpdir(), "jskit-absent-claude-config");
34
+
32
35
  async function fixture(t, body) {
33
36
  const directory = await mkdtemp(join(tmpdir(), "jskit-claude-process-"));
34
37
  t.after(() => rm(directory, { recursive: true, force: true }));
@@ -110,10 +113,10 @@ test("Claude model configuration maps background calls and resets external setti
110
113
  });
111
114
 
112
115
  test("Claude status reads native JSON and returns only public account details", async () => {
113
- const status = await readClaudeCodeAuthStatus({ credentialHome: { home: "/home/fixture" }, commandRunner: async (input) => {
116
+ const status = await readClaudeCodeAuthStatus({ credentialHome: { home: "/home/fixture-json" }, commandRunner: async (input) => {
114
117
  assert.deepEqual(input.args, ["auth", "status", "--json"]);
115
118
  assert.equal(input.timeout, 30_000);
116
- assert.equal(input.credentialHome.home, "/home/fixture");
119
+ assert.equal(input.credentialHome.home, "/home/fixture-json");
117
120
  return { ok: true, stdout: JSON.stringify({ loggedIn: true, email: "owner@example.test", authMethod: "claude.ai", subscriptionType: "max", accessToken: "not-public" }) };
118
121
  } });
119
122
  assert.equal(status.loggedIn, true);
@@ -122,8 +125,10 @@ test("Claude status reads native JSON and returns only public account details",
122
125
  });
123
126
 
124
127
  test("Claude signed-out JSON is normal while failed and malformed status responses stay errors", async () => {
128
+ // Reads of one account share an entry, so each response gets its own account.
129
+ let account = 0;
125
130
  const readStatus = (result) => readClaudeCodeAuthStatus({
126
- credentialHome: { home: "/home/fixture" }, commandRunner: async () => result
131
+ credentialHome: { home: `/home/fixture-signed-out-${account += 1}` }, commandRunner: async () => result
127
132
  });
128
133
  const signedOut = { ok: false, exitCode: 1, stdout: JSON.stringify({ loggedIn: false, authMethod: "none" }) };
129
134
  assert.deepEqual(await readStatus(signedOut), {
@@ -144,7 +149,7 @@ test("Claude signed-out JSON is normal while failed and malformed status respons
144
149
  }
145
150
  });
146
151
 
147
- test("Claude status shares concurrent reads and invalidates when native account files change", async (t) => {
152
+ test("Claude status shares one read per account and invalidates only when its credentials change", async (t) => {
148
153
  const home = await mkdtemp(path.join(os.tmpdir(), "claude-auth-status-"));
149
154
  t.after(() => rm(home, { recursive: true, force: true }));
150
155
  const configRoot = path.join(home, ".claude");
@@ -163,16 +168,32 @@ test("Claude status shares concurrent reads and invalidates when native account
163
168
  await readClaudeCodeAuthStatus(input);
164
169
  assert.equal(calls, 1);
165
170
  }
166
- for (const file of [path.join(configRoot, ".credentials.json"), path.join(home, ".claude.json"), path.join(configRoot, ".claude.json")]) {
167
- email = `${calls}@example.test`;
168
- // Deliberately not JSON: the wrapper must never parse native credentials.
169
- await writeFile(file, "native-account-changed");
170
- assert.equal((await readClaudeCodeAuthStatus(input)).email, email);
171
- await rm(file);
172
- const before = calls;
171
+ // macOS reuses nothing: file metadata cannot show Keychain changes.
172
+ if (process.platform === "darwin") return;
173
+ // Claude Code rewrites .claude.json during ordinary turns: the account is unchanged.
174
+ for (const file of [path.join(home, ".claude.json"), path.join(configRoot, ".claude.json")]) {
175
+ await writeFile(file, "rewritten-during-a-turn");
173
176
  await readClaudeCodeAuthStatus(input);
174
- assert.equal(calls, before + 1);
177
+ assert.equal(calls, 1, `${path.basename(path.dirname(file))}/.claude.json does not bust the read`);
175
178
  }
179
+ // Another command path and host runner reading the same account share its entry.
180
+ const otherRunner = async () => assert.fail("The same account is read once.");
181
+ await readClaudeCodeAuthStatus({ ...input, command: "/opt/other/bin/claude", commandRunner: otherRunner,
182
+ env: { ...input.env, PATH: "/opt/other/bin", HOME: home } });
183
+ assert.equal(calls, 1);
184
+ // Sign-in writes the credentials and sign-out removes them.
185
+ const credentials = path.join(configRoot, ".credentials.json");
186
+ email = "second@example.test";
187
+ // Deliberately not JSON: the wrapper must never parse native credentials.
188
+ await writeFile(credentials, "native-account-changed");
189
+ assert.equal((await readClaudeCodeAuthStatus(input)).email, email);
190
+ assert.equal(calls, 2);
191
+ await rm(credentials);
192
+ await readClaudeCodeAuthStatus(input);
193
+ assert.equal(calls, 3);
194
+ // A different identity variable is a different account.
195
+ await readClaudeCodeAuthStatus({ ...input, env: { ...input.env, CLAUDE_CODE_OAUTH_TOKEN: "fixture-token" } });
196
+ assert.equal(calls, 4);
176
197
  });
177
198
 
178
199
  test("Claude status failures do not poison later reads or share across credential homes", async () => {
@@ -182,15 +203,15 @@ test("Claude status failures do not poison later reads or share across credentia
182
203
  return calls === 1 ? { ok: false, error: "Temporary failure" }
183
204
  : { ok: true, stdout: JSON.stringify({ loggedIn: false }) };
184
205
  };
185
- const input = { env: {}, credentialHome: { home: "/home/fixture" }, commandRunner };
206
+ const input = { env: {}, credentialHome: { home: "/home/fixture-failure" }, commandRunner };
186
207
  assert.equal((await readClaudeCodeAuthStatus(input)).error, "Temporary failure");
187
208
  assert.equal((await readClaudeCodeAuthStatus(input)).error, undefined);
188
209
  assert.equal(calls, 2);
189
- await readClaudeCodeAuthStatus({ ...input, credentialHome: { home: "/home/another" } });
210
+ await readClaudeCodeAuthStatus({ ...input, credentialHome: { home: "/home/fixture-failure-another" } });
190
211
  assert.equal(calls, 3);
191
212
  });
192
213
 
193
- test("a Claude status read that cannot finish keeps Claude's recent answer for unchanged account files", {
214
+ test("a Claude status answer holds until the credentials change, even across its own .claude.json rewrite", {
194
215
  skip: process.platform === "darwin"
195
216
  }, async (t) => {
196
217
  const home = await mkdtemp(path.join(os.tmpdir(), "claude-auth-status-"));
@@ -199,44 +220,45 @@ test("a Claude status read that cannot finish keeps Claude's recent answer for u
199
220
  let now = realNow();
200
221
  Date.now = () => now;
201
222
  t.after(() => { Date.now = realNow; });
223
+ const configRoot = path.join(home, ".claude");
224
+ await mkdir(configRoot);
225
+ const credentials = path.join(configRoot, ".credentials.json");
226
+ await writeFile(credentials, "signed-in");
202
227
  const signedIn = { ok: true, stdout: JSON.stringify({ loggedIn: true, email: "owner@example.test", authMethod: "claude.ai" }) };
203
- const signedInStatus = { loggedIn: true, email: "owner@example.test", authMethod: "claude.ai", subscriptionType: "" };
204
- const timedOut = { ok: false, exitCode: 1, timedOut: true, error: "Command timed out." };
205
228
  let calls = 0;
206
229
  let result = signedIn;
207
- const input = { env: { CLAUDE_CONFIG_DIR: path.join(home, ".claude") }, credentialHome: { home },
208
- commandRunner: async () => { calls += 1; return result; } };
230
+ const input = { env: { CLAUDE_CONFIG_DIR: configRoot }, credentialHome: { home }, commandRunner: async () => {
231
+ calls += 1;
232
+ // Each native status run rewrites ~/.claude.json a few seconds later.
233
+ await writeFile(path.join(home, ".claude.json"), `status-run-${calls}`);
234
+ return result;
235
+ } };
209
236
  assert.equal((await readClaudeCodeAuthStatus(input)).email, "owner@example.test");
210
- // An overloaded host times the next read out, or refuses to start it for now.
211
- for (const unfinished of [timedOut, { ok: false, exitCode: 1, retryable: true, error: "Memory-pressure spike." }]) {
212
- now += 31_000;
213
- result = unfinished;
214
- assert.deepEqual(await readClaudeCodeAuthStatus(input), signedInStatus);
215
- }
216
- // That answer is reused for the normal window, so the busy host is not probed again at once.
217
- const probes = calls;
237
+ now += 10 * 60_000;
238
+ assert.equal((await readClaudeCodeAuthStatus(input)).email, "owner@example.test");
239
+ assert.equal(calls, 1, "the answer is not read again on a timer or after the run's own rewrite");
240
+ // Sign-out removes the credentials: Claude is asked again at once.
241
+ await rm(credentials);
242
+ result = { ok: false, exitCode: 1, stdout: JSON.stringify({ loggedIn: false }) };
243
+ assert.equal((await readClaudeCodeAuthStatus(input)).loggedIn, false);
244
+ assert.equal(calls, 2);
245
+ // A read the host timed out is reported and not asked again at once.
246
+ await writeFile(credentials, "signed-in-again");
247
+ result = { ok: false, exitCode: 1, timedOut: true, error: "Command timed out." };
248
+ assert.equal((await readClaudeCodeAuthStatus(input)).error, "Command timed out.");
218
249
  now += 29_000;
219
- assert.deepEqual(await readClaudeCodeAuthStatus(input), signedInStatus);
220
- assert.equal(calls, probes);
221
- // A read that ran and failed is still reported.
250
+ assert.equal((await readClaudeCodeAuthStatus(input)).error, "Command timed out.");
251
+ assert.equal(calls, 3);
252
+ // A read that ran and failed is reported, and asked again next time.
222
253
  now += 2_000;
223
254
  result = { ok: false, exitCode: 2, error: "Claude Code crashed." };
224
255
  assert.equal((await readClaudeCodeAuthStatus(input)).error, "Claude Code crashed.");
225
- // Changed account files need Claude's own new answer.
226
- await mkdir(path.join(home, ".claude"));
227
- await writeFile(path.join(home, ".claude", ".credentials.json"), "native-account-changed");
228
- result = timedOut;
229
- assert.equal((await readClaudeCodeAuthStatus(input)).error, "Command timed out.");
230
- // The recent answer expires: an old one is not kept forever.
231
- now += 31_000;
232
256
  result = signedIn;
233
257
  assert.equal((await readClaudeCodeAuthStatus(input)).loggedIn, true);
234
- now += 5 * 60_000 + 1;
235
- result = timedOut;
236
- assert.equal((await readClaudeCodeAuthStatus(input)).error, "Command timed out.");
258
+ assert.equal(calls, 5);
237
259
  });
238
260
 
239
- test("an unfinished Claude status read with no recent answer is reported without probing again at once", {
261
+ test("an unfinished Claude status read is reported without probing again at once", {
240
262
  skip: process.platform === "darwin"
241
263
  }, async (t) => {
242
264
  const realNow = Date.now;
@@ -535,13 +557,18 @@ test("Claude account queries isolate concurrent identities and deduplicate each
535
557
  for (const method of ["readCatalogue", "readPlanUsage"]) {
536
558
  const pending = new Map(["first", "second"].map(id => [id, Promise.withResolvers()]));
537
559
  const calls = [];
560
+ const native = id => ({ command: process.execPath, async stop() { return { scopeEmpty: true }; },
561
+ get initialization() { calls.push(id); return pending.get(id).promise; },
562
+ client: { request() { calls.push(id); return pending.get(id).promise; } } });
563
+ // Plan usage borrows each account's conversation; the model list starts its own Claude.
564
+ const starting = ["first", "second"];
538
565
  const queries = createClaudeAccountQueries({
539
- accountIdentity: async context => context.id,
540
- processes: () => [...pending].map(([id, held]) => ({ accountIdentity: id, process: {
541
- get initialization() { calls.push(id); return held.promise; },
542
- client: { request() { calls.push(id); return held.promise; } }
543
- } })),
544
- createProcess() { throw new Error("An owned account process should be reused."); }
566
+ accountIdentity: async context => context.id, configRoot: ABSENT_CLAUDE_CONFIG,
567
+ processes: () => method === "readPlanUsage" ? starting.map(id => ({ accountIdentity: id, process: native(id) })) : [],
568
+ createProcess() {
569
+ if (method === "readPlanUsage") throw new Error("An owned account process should be reused.");
570
+ return native(starting.shift());
571
+ }
545
572
  });
546
573
  const first = queries[method]({ id: "first" });
547
574
  const duplicate = queries[method]({ id: "first" });
@@ -683,15 +710,131 @@ test("Claude plan usage starts one temporary Claude per idle interval and marks
683
710
  await queries.invalidate();
684
711
  });
685
712
 
713
+ test("Claude plan usage starts one temporary Claude per account for every owner polling it", async (t) => {
714
+ const realNow = Date.now;
715
+ let now = Date.parse("2026-10-29T03:00:00Z");
716
+ Date.now = () => now;
717
+ t.after(() => { Date.now = realNow; });
718
+ const starts = [];
719
+ let stops = 0;
720
+ // Three account-query owners in one server process, none with a live conversation.
721
+ const owners = ["first", "second", "third"].map(owner => createClaudeAccountQueries({
722
+ accountIdentity: async context => context.account,
723
+ createProcess(input) {
724
+ starts.push([owner, input.purpose]);
725
+ return { client: { request: async () => ({ rate_limits_available: true, rate_limits: {
726
+ seven_day: { utilization: 23, resets_at: "2026-11-04T05:00:00Z" }, limits: [] } }) },
727
+ async stop() { stops++; return { scopeEmpty: true }; } };
728
+ }
729
+ }));
730
+ t.after(() => Promise.all(owners.map(queries => queries.invalidate())));
731
+ const account = { account: "plan-usage-shared-across-owners" };
732
+ // Requests that arrive while the account is read share that one read.
733
+ const concurrent = await Promise.all(owners.map(queries => queries.readPlanUsage(account)));
734
+ assert.deepEqual(starts, [["first", "usage"]]);
735
+ for (const reading of concurrent) assert.deepEqual(reading, concurrent[0]);
736
+ assert.equal(concurrent[0].windows[0].remainingPercent, 77);
737
+ // An hour of every owner polling once a minute.
738
+ for (let minute = 1; minute < 60; minute++) {
739
+ now += 60_000;
740
+ for (const queries of owners) assert.equal((await queries.readPlanUsage(account)).status, "available");
741
+ }
742
+ assert.equal(starts.length, 12, "the account started more than one temporary Claude per idle interval");
743
+ assert.equal(stops, starts.length, "a temporary usage process outlived its read");
744
+ // Another account is read on its own.
745
+ await owners[1].readPlanUsage({ account: "plan-usage-another-account" });
746
+ assert.equal(starts.length, 13);
747
+ });
748
+
749
+ test("Claude plan usage reuses another owner's live reading instead of starting a temporary Claude", async (t) => {
750
+ const realNow = Date.now;
751
+ let now = Date.parse("2026-10-29T03:00:00Z");
752
+ Date.now = () => now;
753
+ t.after(() => { Date.now = realNow; });
754
+ const account = "plan-usage-live-in-another-owner";
755
+ const answer = { rate_limits_available: true, rate_limits: {
756
+ seven_day: { utilization: 23, resets_at: "2026-11-04T05:00:00Z" }, limits: [] } };
757
+ let liveReads = 0;
758
+ const starts = [];
759
+ const createProcess = () => {
760
+ starts.push("usage");
761
+ return { client: { request: async () => answer }, async stop() { return { scopeEmpty: true }; } };
762
+ };
763
+ const withConversation = createClaudeAccountQueries({ accountIdentity: async () => account, createProcess,
764
+ processes: () => [{ accountIdentity: account, process: { client: { request: async () => { liveReads++; return answer; } } } }] });
765
+ const idle = createClaudeAccountQueries({ accountIdentity: async () => account, createProcess });
766
+ t.after(() => Promise.all([withConversation.invalidate(), idle.invalidate()]));
767
+ const live = await withConversation.readPlanUsage({});
768
+ assert.equal(liveReads, 1);
769
+ // Only the idle owner's page stays open: the account's verified reading serves it
770
+ // until that reading is an idle interval old.
771
+ for (let minute = 1; minute <= 4; minute++) {
772
+ now += 60_000;
773
+ const reading = await idle.readPlanUsage({});
774
+ assert.equal(reading.checkedAt, live.checkedAt);
775
+ assert.equal(reading.status, "available");
776
+ }
777
+ assert.deepEqual(starts, [], "a temporary Claude started while the account had a recent verified reading");
778
+ now += 60_000;
779
+ await idle.readPlanUsage({});
780
+ assert.deepEqual(starts, ["usage"]);
781
+ });
782
+
783
+ test("Claude model list starts a Claude only after its account, credentials or Claude Code changed", async (t) => {
784
+ const root = await mkdtemp(join(tmpdir(), "jskit-claude-catalogue-"));
785
+ t.after(() => rm(root, { recursive: true, force: true }));
786
+ const configRoot = join(root, ".claude");
787
+ await mkdir(configRoot);
788
+ // Sign-in, a token refresh and an update each replace the file at a stable path.
789
+ const replace = async (file, contents) => {
790
+ await writeFile(`${file}.next`, contents);
791
+ await rename(`${file}.next`, file);
792
+ };
793
+ const credentials = join(configRoot, ".credentials.json");
794
+ const command = join(root, "claude");
795
+ await replace(credentials, "signed in");
796
+ await replace(command, "2.1.1");
797
+ const realNow = Date.now;
798
+ let now = realNow();
799
+ Date.now = () => now;
800
+ t.after(() => { Date.now = realNow; });
801
+ let account = "account";
802
+ const starts = [];
803
+ const queries = createClaudeAccountQueries({ accountIdentity: async () => account, configRoot,
804
+ // A conversation that started before every change below.
805
+ processes: () => [{ accountIdentity: account, process: { command, initialization: { models: [{ value: "live" }] } } }],
806
+ createProcess({ purpose }) {
807
+ starts.push(purpose);
808
+ return { command, initialization: { models: [{ value: `read-${starts.length}` }] },
809
+ async stop() { return { scopeEmpty: true }; } };
810
+ } });
811
+ const model = async () => (await queries.readCatalogue({})).models[0].value;
812
+ // Reconnects, tab returns and closed sessions ask about four times an hour.
813
+ const askForADay = async (expected) => {
814
+ for (let read = 0; read < 96; read++, now += 15 * 60_000) assert.equal(await model(), expected);
815
+ };
816
+ await askForADay("read-1");
817
+ assert.deepEqual(starts, ["catalogue"]);
818
+ await replace(credentials, "refreshed");
819
+ await askForADay("read-2");
820
+ await replace(command, "2.1.2");
821
+ await askForADay("read-3");
822
+ account = "other account";
823
+ await askForADay("read-4");
824
+ assert.deepEqual(starts, ["catalogue", "catalogue", "catalogue", "catalogue"]);
825
+ await queries.invalidate();
826
+ assert.equal(await model(), "read-5");
827
+ });
828
+
686
829
  test("Claude account queries label temporary startup and reuse each account's recent reading", async () => {
687
830
  const starts = [];
688
831
  let stops = 0;
689
832
  const queries = createClaudeAccountQueries({
690
- accountIdentity: async () => "private-account-identity",
833
+ accountIdentity: async () => "private-account-identity", configRoot: ABSENT_CLAUDE_CONFIG,
691
834
  createProcess(input) {
692
835
  starts.push(input);
693
836
  return {
694
- initialization: { models: [] },
837
+ initialization: { models: [] }, command: process.execPath,
695
838
  client: { request: async () => ({ rate_limits_available: false }) },
696
839
  async stop() { stops++; return { scopeEmpty: true }; }
697
840
  };
@@ -753,10 +896,11 @@ for (const method of ["readCatalogue", "readPlanUsage"]) {
753
896
  // Usage reads count as native reads of the borrowed conversation process.
754
897
  processes: () => method === "readPlanUsage" ? [{ accountIdentity: "account", process: { client: {
755
898
  request: async () => { starts++; return { rate_limits_available: true, rate_limits: { limits: [] } }; } } } }] : [],
899
+ configRoot: ABSENT_CLAUDE_CONFIG,
756
900
  createProcess() {
757
901
  starts++;
758
902
  return {
759
- initialization: { models: [{ value: `model-${starts}` }] },
903
+ initialization: { models: [{ value: `model-${starts}` }] }, command: process.execPath,
760
904
  client: { request: async () => ({ rate_limits_available: true, rate_limits: {} }) },
761
905
  async stop() {
762
906
  stops++;
@@ -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);
@@ -926,15 +936,50 @@ test("Claude status retains active instructions and applies changed guidance onc
926
936
  assert.equal(f.processes.length, 2, "Idle readiness installs the changed instructions");
927
937
  });
928
938
 
929
- 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 => {
930
940
  const f = await publishedClaudeFixture(t);
931
941
  await f.send();
942
+ const running = f.entry.process;
932
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.
933
949
  await assert.rejects(f.owner.ensureReady(f.entry), { code: "claude_account_changed" });
934
950
  assert.equal(f.entry.process, null);
935
951
  });
936
952
 
937
- 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 => {
938
983
  const f = await publishedClaudeFixture(t);
939
984
  f.context.sessionId = "session-a";
940
985
  await f.send();
@@ -944,7 +989,7 @@ test("Claude account change stops running processes without reopening saved conv
944
989
  readContext: async () => { throw new Error("A saved conversation was reopened"); },
945
990
  terminals: { close: async sessionId => { closedTerminals.push(sessionId); return { ok: true, closed: 0 }; } }
946
991
  };
947
- const result = await f.owner.invalidateRuntimes({ provider: "claude", reason: "claude-auth-change" }, application,
992
+ const result = await f.owner.invalidateRuntimes({ reason: "server-shutdown" }, application,
948
993
  { queries: { invalidate: async () => {} }, failure: error => { throw error; } });
949
994
  assert.equal(result.ok, true);
950
995
  assert.equal(f.entry.process, null, "the running process stopped");
@@ -1871,6 +1916,97 @@ test("consecutive steers queue together without waiting for or sending an interr
1871
1916
  assert.deepEqual(events.filter(event => event.type === "diagnostic" && event.stage === "steering_queued").length, 2);
1872
1917
  });
1873
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
+
1874
2010
  test("folded internal command preserves the original published answer owner", async t => {
1875
2011
  const f = await publishedClaudeFixture(t);
1876
2012
  await f.send();
@@ -2305,6 +2441,41 @@ for (const action of ["steer", "set", "resume"]) test(`Claude ${action} queues a
2305
2441
  assert.equal(f.entry.nativeTurn.read().active, false, "The main response settles the completed worker's turn");
2306
2442
  });
2307
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
+
2308
2479
  test("Claude context inspection reuses the native owner and reports the selected model's current usage", async t => {
2309
2480
  const f = await publishedClaudeFixture(t);
2310
2481
  const requests = [];