@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.
- package/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
- package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
- package/locales/en.json +4 -2
- package/package.json +5 -5
- package/src/client/conversation/AssistantConversationElement.vue +8 -1
- package/src/client/conversation/AssistantGoalControl.vue +2 -2
- package/src/client/conversation/AssistantTranscript.vue +49 -12
- package/src/client/conversation/messageDelivery.js +23 -6
- package/src/client/lib/assistantApi.js +5 -0
- package/src/server/conversation/claudeProcess.js +105 -58
- package/src/server/conversation/claudeTurn.js +179 -59
- package/src/server/conversation/codexAuthPreflight.js +3 -0
- package/src/server/conversation/codexConfiguration.js +42 -4
- package/src/server/conversation/codexConversations.js +256 -114
- package/src/server/conversation/codexEvents.js +16 -3
- package/src/server/conversation/codexHelperThreadRecovery.js +17 -4
- package/src/server/conversation/codexHelperThreads.js +10 -1
- package/src/server/conversation/codexHomeStorage.js +69 -0
- package/src/server/conversation/codexProcess.js +7 -2
- package/src/server/conversation/codexProvider.js +57 -46
- package/src/server/conversation/codexProviderOwner.js +234 -29
- package/src/server/conversation/codexProviderSelection.js +13 -3
- package/src/server/conversation/codexRuntimeLifecycle.js +56 -4
- package/src/server/conversation/codexSessionCleanup.js +67 -20
- package/src/server/conversation/codexSessionReconciliation.js +18 -4
- package/src/server/conversation/codexTurn.js +9 -2
- package/src/server/conversation/continuity.js +13 -1
- package/src/server/conversation/openCodeClient.js +34 -0
- package/src/server/conversation/openCodeProcess.js +37 -5
- package/src/server/conversation/openCodeRuntime.js +225 -64
- package/src/server/conversation/providers/claudeDriver.js +3 -3
- package/src/server/conversation/providers/codexDriver.js +10 -4
- package/src/server/conversation/providers/opencodeDriver.js +4 -2
- package/src/server/conversation/runtime.js +72 -2
- package/src/server/hosts/codexConfiguration.js +4 -0
- package/test/assistantScroll.browser.test.js +169 -10
- package/test/claudeLifecycle.test.js +293 -0
- package/test/claudeProcess.test.js +195 -51
- package/test/claudeTurn.test.js +174 -3
- package/test/codexAccountChange.test.js +261 -0
- package/test/codexConversationsCheckpoint.test.js +3 -0
- package/test/codexEvents.test.js +23 -1
- package/test/codexHomeStorage.test.js +57 -0
- package/test/codexInteractiveArguments.test.js +9 -0
- package/test/codexLifecycleIsolation.test.js +441 -0
- package/test/codexModelCatalog.test.js +48 -2
- package/test/codexProcess.test.js +21 -0
- package/test/codexTemporaryProviderRelease.test.js +180 -0
- package/test/codexTurn.test.js +21 -1
- package/test/conversationNativeStorage.test.js +57 -0
- package/test/conversationRuntime.test.js +66 -0
- package/test/conversationSession.test.js +4 -8
- package/test/messageDelivery.browser.test.js +18 -2
- package/test/messageDelivery.test.js +34 -1
- package/test/openCodeClient.test.js +31 -0
- package/test/openCodeConnectionChange.test.js +166 -0
- package/test/openCodeProcess.test.js +36 -0
- 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:
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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,
|
|
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
|
|
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:
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
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.
|
|
220
|
-
assert.equal(calls,
|
|
221
|
-
// A read that ran and failed is
|
|
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
|
-
|
|
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
|
|
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: () =>
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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++;
|
package/test/claudeTurn.test.js
CHANGED
|
@@ -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
|
|
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
|
|
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({
|
|
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 = [];
|