@jskit-ai/assistant-core 0.1.226 → 0.1.228

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 (37) hide show
  1. package/examples/conversation/package.json +1 -1
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +5 -3
  3. package/fixtures/responsive-assistant/DeliveryFixture.vue +9 -0
  4. package/package.json +1 -1
  5. package/src/client/conversation/AssistantConversationElement.vue +7 -4
  6. package/src/client/conversation/AssistantGoalControl.vue +31 -17
  7. package/src/client/conversation/AssistantTranscript.vue +14 -7
  8. package/src/client/conversation/useAssistantAttachments.js +6 -3
  9. package/src/server/conversation/claudeClient.js +22 -5
  10. package/src/server/conversation/claudeHistory.js +62 -5
  11. package/src/server/conversation/claudeProcess.js +7 -6
  12. package/src/server/conversation/claudeTurn.js +284 -57
  13. package/src/server/conversation/codexConfiguration.js +24 -1
  14. package/src/server/conversation/codexGoalCommands.js +8 -3
  15. package/src/server/conversation/codexTurnRecovery.js +18 -8
  16. package/src/server/conversation/continuity.js +54 -17
  17. package/src/server/conversation/providers/claudeDriver.js +6 -5
  18. package/src/server/conversation/providers/codexDriver.js +3 -3
  19. package/src/server/conversation/providers/scoped.js +31 -10
  20. package/src/server/conversation/runtime.js +48 -13
  21. package/src/server/conversation/streams.js +7 -3
  22. package/src/server/conversation/transcript.js +2 -1
  23. package/src/server/hosts/codexConfiguration.js +1 -0
  24. package/src/shared/conversation/longTextBlocks.js +0 -4
  25. package/test/assistantScroll.browser.test.js +76 -3
  26. package/test/claudeClient.test.js +73 -0
  27. package/test/claudeConversationSession.test.js +120 -4
  28. package/test/claudeProcess.test.js +61 -0
  29. package/test/claudeTurn.test.js +1326 -10
  30. package/test/codexConversationSession.test.js +9 -1
  31. package/test/codexGoal.test.js +47 -0
  32. package/test/codexModelCatalog.test.js +55 -0
  33. package/test/codexTurn.test.js +228 -0
  34. package/test/conversationAttachments.test.js +17 -0
  35. package/test/conversationRuntime.test.js +40 -0
  36. package/test/conversationStreaming.test.js +18 -0
  37. package/test/messageDelivery.browser.test.js +30 -0
@@ -209,10 +209,6 @@ function parsePipeTableAt(lines = [], startIndex = 0) {
209
209
  if (!headers || !alignments || headers.length !== alignments.length) {
210
210
  return null;
211
211
  }
212
- if (!headers.some((header) => header)) {
213
- return null;
214
- }
215
-
216
212
  const rows = [];
217
213
  let rowIndex = startIndex + 2;
218
214
  while (rowIndex < lines.length) {
@@ -716,6 +716,42 @@ test("working state appears before output and suggestion agents and activity ren
716
716
  });
717
717
 
718
718
 
719
+ test("long goal objectives keep Start reachable on short phone, tablet and desktop screens", {
720
+ skip: !RUN_BROWSER_TEST,
721
+ timeout: 60_000
722
+ }, async () => {
723
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
724
+ const browser = await chromium.launch(createChromiumLaunchOptions());
725
+ try {
726
+ const objective = "Complete the requested work and verify the result. ".repeat(80).slice(0, 3990);
727
+ for (const width of [390, 800, 1280]) {
728
+ const page = await browser.newPage({ viewport: { width, height: 578 } });
729
+ await page.goto(`${vite.baseURL}/?conversation=1&goal=1`);
730
+ await page.getByRole("button", { name: "Set goal", exact: true }).click();
731
+ await page.getByRole("textbox", { name: "Goal objective" }).fill(objective);
732
+ const start = page.getByRole("button", { name: "Start goal", exact: true });
733
+ await expect(start).toBeInViewport();
734
+ await expect.poll(() => start.evaluate(element => {
735
+ const bounds = element.getBoundingClientRect();
736
+ return bounds.top >= 0 && bounds.bottom <= innerHeight &&
737
+ element.contains(document.elementFromPoint(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2));
738
+ })).toBe(true);
739
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
740
+ await page.screenshot({ path: `/tmp/jskit-goal-long-objective-${width}.png` });
741
+ await start.click();
742
+ const running = page.getByRole("button", { name: "Goal running", exact: true });
743
+ await expect(running).toHaveAttribute("aria-expanded", "false");
744
+ await running.click();
745
+ await expect(page.locator(".assistant-goal__details").getByText(objective, { exact: true })).toHaveText(objective);
746
+ await expect(page.getByRole("button", { name: "Pause goal", exact: true })).toBeInViewport();
747
+ await page.close();
748
+ }
749
+ } finally {
750
+ await browser.close();
751
+ await stopProcess(vite);
752
+ }
753
+ });
754
+
719
755
  test("optional goals show running and paused lights, count only active time, and adapt to pane width", {
720
756
  skip: !RUN_BROWSER_TEST,
721
757
  timeout: 60_000
@@ -733,13 +769,20 @@ test("optional goals show running and paused lights, count only active time, and
733
769
  await page.getByRole("textbox", { name: "Goal objective" }).fill("Complete the shared assistant.");
734
770
  await page.getByRole("spinbutton", { name: "Token budget (optional)" }).fill("10000");
735
771
  await page.getByRole("button", { name: "Start goal", exact: true }).click();
736
- await expect(page.getByText("Complete the shared assistant.", { exact: true })).toBeVisible();
772
+ await page.clock.runFor(250);
773
+ await expect(page.getByRole("button", { name: "Goal running", exact: true })).toHaveAttribute("aria-expanded", "false");
774
+ await expect(page.getByRole("textbox", { name: "Goal objective" })).toBeHidden();
737
775
  await input.click();
738
776
  await input.fill("Preserve this draft and caret");
739
777
  await input.evaluate(element => element.setSelectionRange(5, 9));
740
778
  const light = page.locator(".assistant-goal__light");
741
779
  await expect(light).toHaveCSS("background-color", "rgb(211, 47, 47)");
742
780
  await expect(light).toHaveCSS("animation-name", /^assistant-goal-flash/u);
781
+ await page.getByRole("button", { name: "External idle", exact: true }).evaluate(element => element.click());
782
+ await expect(light).toHaveCSS("animation-name", "none");
783
+ await expect(light).toHaveCSS("background-color", "rgb(211, 47, 47)");
784
+ await page.getByRole("button", { name: "External active", exact: true }).evaluate(element => element.click());
785
+ await expect(light).toHaveCSS("animation-name", /^assistant-goal-flash/u);
743
786
  const elapsed = page.locator(".assistant-goal__elapsed");
744
787
  await page.clock.fastForward(65_000);
745
788
  await expect(elapsed).toHaveText("1:05");
@@ -766,6 +809,23 @@ test("optional goals show running and paused lights, count only active time, and
766
809
  await page.getByRole("button", { name: "Goal running", exact: true }).click();
767
810
  await page.clock.runFor(250);
768
811
  await expect(page.getByText("Running time: 1:10", { exact: true })).toBeVisible();
812
+ await input.click();
813
+ await page.clock.runFor(250);
814
+ await page.setViewportSize({ width: 1280, height: 577 });
815
+ await page.getByRole("button", { name: "Block goal with long explanation", exact: true }).click();
816
+ await expect(light).toHaveCSS("animation-name", "none");
817
+ await page.getByRole("button", { name: "Goal blocked", exact: true }).click();
818
+ await page.clock.runFor(250);
819
+ for (const name of ["Pause goal", "Resume goal", "Cancel goal"]) {
820
+ const button = page.getByRole("button", { name, exact: true });
821
+ await expect(button).toBeInViewport();
822
+ const bounds = await button.boundingBox();
823
+ assert.ok(bounds.y >= 0 && bounds.y + bounds.height <= 577, `${name} is fully visible without scrolling`);
824
+ }
825
+ await page.getByRole("button", { name: "Pause goal", exact: true }).click();
826
+ await expect(page.getByRole("button", { name: "Goal paused", exact: true })).toBeVisible();
827
+ await page.getByRole("button", { name: "Resume goal", exact: true }).click();
828
+ await expect(page.getByRole("button", { name: "Pause goal", exact: true })).toBeVisible();
769
829
  await page.getByRole("button", { name: "Toggle goals" }).evaluate(element => element.click());
770
830
  await expect(page.locator(".assistant-goal")).toHaveCount(0);
771
831
  await expect(page.getByRole("button", { name: "Pause goal", exact: true })).toBeHidden();
@@ -868,6 +928,7 @@ test("an optional avatar clamps in a short conversation without consuming its pr
868
928
  };
869
929
  assert.equal(Math.round((await panel.boundingBox()).width), 320);
870
930
  assert.equal(Math.round((await panel.boundingBox()).height), 420);
931
+ await expect(artwork).toHaveCSS("contain", "layout paint");
871
932
  await expect.poll(async () => (await artwork.boundingBox()).height).toBeGreaterThan(64);
872
933
  assert.ok((await artwork.boundingBox()).height <= 96, "Compact artwork preserves the existing short-container clamp");
873
934
  await page.getByRole("button", { name: "Resize height", exact: true }).evaluate(element => element.click());
@@ -1160,6 +1221,7 @@ test("connection warnings keep avatar controls and Reload reachable without repl
1160
1221
  for (const width of [320, 800]) {
1161
1222
  const page = await browser.newPage({ viewport: { width, height: 1000 } });
1162
1223
  await page.goto(`${vite.baseURL}/?conversation=1&avatar=1&avatarTools=1&update-errors=1`);
1224
+ await page.locator(".fixture").evaluate(element => { element.style.width = "100%"; });
1163
1225
  const external = name => page.getByRole("button", { name, exact: true }).evaluate(element => element.click());
1164
1226
  await external("Resize height");
1165
1227
  const conversation = page.locator(".assistant-conversation");
@@ -1179,14 +1241,25 @@ test("connection warnings keep avatar controls and Reload reachable without repl
1179
1241
  let reloads = 0;
1180
1242
  for (const size of ["standard", "large", "hidden"]) {
1181
1243
  await page.getByRole("combobox", { name: "Avatar preference", exact: true }).selectOption(size);
1244
+ // Establish the reading position after changing the fixture-only avatar preference.
1245
+ await body.evaluate(element => { element.scrollTop = 420; });
1246
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
1182
1247
  await external("Show update error");
1183
1248
  await expect(conversation).toHaveClass(/assistant-conversation--error/u);
1184
- await expect(avatar).toHaveCSS("position", "relative");
1249
+ await expect(avatar).toHaveCSS("position", "absolute");
1185
1250
  await expect(banner).toContainText("Conversation updates are temporarily unavailable.");
1186
1251
  await expect(reload).toBeVisible();
1187
1252
  const avatarBox = await avatar.boundingBox();
1188
1253
  const bannerBox = await banner.boundingBox();
1189
- assert.ok(avatarBox.y + avatarBox.height <= bannerBox.y, "The entire avatar and its controls stay above the warning");
1254
+ assert.ok(avatarBox.y >= bannerBox.y + bannerBox.height - 1, "The compact warning stays above the overlaid avatar");
1255
+ const transcriptBox = await page.locator(".assistant-transcript").boundingBox();
1256
+ assert.ok(bannerBox.y < transcriptBox.y + 12, "Avatar height cannot push the warning below the top of chat");
1257
+ assert.ok(bannerBox.width >= transcriptBox.width - 20, "Warning spans the chat width across the avatar area");
1258
+ const wordsBox = await banner.locator(".assistant-transcript__error-content > span").boundingBox();
1259
+ const reloadBox = await reload.boundingBox();
1260
+ assert.ok(reloadBox.x + reloadBox.width <= wordsBox.x, "Reload stays left of the status text");
1261
+ if (size === "standard") await page.screenshot({ path: `/tmp/sas25-connection-${width}.png` });
1262
+ assert.ok(bannerBox.height < (width === 320 ? 150 : 75), `The row wraps compactly instead of reserving avatar height: width=${width}, height=${bannerBox.height}`);
1190
1263
  const controls = size === "hidden"
1191
1264
  ? [page.getByRole("button", { name: "Retained avatar control", exact: true }), page.getByRole("button", { name: "Show avatar", exact: true })]
1192
1265
  : [page.getByRole("button", { name: "Avatar tool", exact: true }), page.getByRole("button", { name: "Minimise avatar", exact: true })];
@@ -32,6 +32,47 @@ function fakeStream(onWrite) {
32
32
  } });
33
33
  }
34
34
 
35
+ test("Claude initialization diagnostics distinguish a held write from its matched response without affecting completion", async t => {
36
+ for (const responseFirst of [false, true]) await t.test(`response first: ${responseFirst}`, async () => {
37
+ const entered = Promise.withResolvers();
38
+ let finishWrite;
39
+ const stages = [];
40
+ const stream = new Duplex({ read() {}, write(chunk, _encoding, callback) {
41
+ finishWrite = () => { finishWrite = undefined; callback(); };
42
+ entered.resolve(JSON.parse(String(chunk)));
43
+ } });
44
+ const client = createClaudeJsonClient({ stream });
45
+ let settled = false;
46
+ const initialization = client.initialize({}, { onProgress(stage) {
47
+ stages.push(stage);
48
+ throw new Error("Diagnostic observer failed");
49
+ } }).then(result => { settled = true; return result; });
50
+ try {
51
+ const frame = await entered.promise;
52
+ assert.deepEqual(stages, ["requested"]);
53
+ const reply = requestId => stream.push(`${JSON.stringify({ type: "control_response", response: {
54
+ subtype: "success", request_id: requestId, response: { privateValue: "secret" }
55
+ } })}\n`);
56
+ reply("unmatched");
57
+ await new Promise(resolve => setImmediate(resolve));
58
+ assert.deepEqual(stages, ["requested"]);
59
+ if (responseFirst) reply(frame.request_id);
60
+ else finishWrite();
61
+ await new Promise(resolve => setImmediate(resolve));
62
+ assert.deepEqual(stages, ["requested", responseFirst ? "response_received" : "written"]);
63
+ assert.equal(settled, false, "both the write and matching response remain required");
64
+ if (responseFirst) finishWrite();
65
+ else reply(frame.request_id);
66
+ assert.deepEqual(await initialization, { privateValue: "secret" });
67
+ assert.deepEqual(stages, ["requested", ...(responseFirst ? ["response_received", "written"] : ["written", "response_received"])]);
68
+ } finally {
69
+ finishWrite?.();
70
+ client.close();
71
+ await client.completion;
72
+ }
73
+ });
74
+ });
75
+
35
76
  test("Claude controls correlate out-of-order replies and events apply backpressure", async () => {
36
77
  const requests = [];
37
78
  const observed = [];
@@ -228,3 +269,35 @@ test("unregistered headless controls are denied and repeated active control IDs
228
269
  second.close();
229
270
  await second.completion;
230
271
  });
272
+
273
+ test("Claude targeted Send now writes its named input first and preserves ordinary Stop", async () => {
274
+ const writes = [];
275
+ const stream = fakeStream((frame, socket) => {
276
+ writes.push(frame);
277
+ if (frame.type === "control_request") socket.push(`${JSON.stringify({ type: "control_response", response: {
278
+ subtype: "success", request_id: frame.request_id, response: { send_now: "delivering", still_queued: ["follow-up"] }
279
+ } })}\n`);
280
+ });
281
+ const client = createClaudeJsonClient({ stream });
282
+ try {
283
+ assert.equal(client.supportsSendNow(), false);
284
+ stream.push(`${JSON.stringify({ type: "system", subtype: "init", capabilities: ["interrupt_send_now_v1"] })}\n`);
285
+ await new Promise(resolve => setImmediate(resolve));
286
+ assert.equal(client.supportsSendNow(), true);
287
+ await client.send("New instruction", { messageId: "follow-up", sessionId: "session", sendNow: true });
288
+ assert.deepEqual(writes.map(frame => frame.type), ["user", "control_request"]);
289
+ assert.deepEqual(writes[1].request, { subtype: "interrupt", send_now: true, message_uuid: "follow-up" });
290
+ await client.interrupt();
291
+ assert.deepEqual(writes.at(-1).request, { subtype: "interrupt" }, "Explicit Stop is still a Stop");
292
+ } finally { client.close(); }
293
+ });
294
+
295
+ test("Claude never sends an unsupported Send now that an older CLI would interpret as Stop", async () => {
296
+ const writes = [];
297
+ const stream = fakeStream(frame => writes.push(frame));
298
+ const client = createClaudeJsonClient({ stream });
299
+ try {
300
+ await assert.rejects(client.send("Keep workers", { sendNow: true }), /does not support.*Send now/);
301
+ assert.equal(writes.length, 0, "Capability failure precedes both input and control writes");
302
+ } finally { client.close(); }
303
+ });
@@ -72,6 +72,20 @@ test("Claude exposes the durable native identity before dispatch and respects a
72
72
  } finally { await provider.dispose(); }
73
73
  });
74
74
 
75
+ test("Claude rejects a finished-turn steer before admission and permits the same ordinary message once", async t => {
76
+ const f = await fixture(t);
77
+ await f.conversation.send(input);
78
+ await f.conversation.wait();
79
+ const next = { messageId: "late-steer", text: "Keep working", steer: true };
80
+ await assert.rejects(f.conversation.send(next), { code: "conversation_steer_turn_finished", delivery: "not-sent" });
81
+ assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
82
+ const { steer, ...ordinary } = next;
83
+ await f.conversation.send(ordinary);
84
+ await f.conversation.wait();
85
+ await f.conversation.send(ordinary);
86
+ assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 2);
87
+ });
88
+
75
89
  test("native Claude coding tools require the server-side host grant", async t => {
76
90
  const f = await fixture(t, { nativeTools: true });
77
91
  await f.conversation.send(input);
@@ -358,7 +372,7 @@ async function fixture(t, options = {}) {
358
372
  function runtime() {
359
373
  const value = createConversationRuntime({ engine: "claude", storage, authorize: options.authorize || (() => true), actions: options.actions,
360
374
  connections: options.connections, attachments: options.attachments, persistCommentary: options.persistCommentary,
361
- host: { workdir: directory, env, commands: { claude: command }, execution: options.execution, nativeTools: options.nativeTools, commandWrapper: options.commandWrapper },
375
+ host: { workdir: directory, env, commands: { claude: command }, allowAccountChange: options.allowAccountChange, execution: options.execution, nativeTools: options.nativeTools, commandWrapper: options.commandWrapper },
362
376
  limits: { admissionTimeoutMs: 150, ...options.limits } });
363
377
  runtimes.push(value);
364
378
  return value;
@@ -367,7 +381,7 @@ async function fixture(t, options = {}) {
367
381
  const conversation = await first.open({ id: "conversation", configuration: options.configuration || configuration, context: options.context });
368
382
  return { directory, storage, account, runtime, first, conversation,
369
383
  driverOptions: { connections: options.connections,
370
- host: { workdir: directory, env, commands: { claude: command }, execution: options.execution, nativeTools: options.nativeTools, commandWrapper: options.commandWrapper },
384
+ host: { workdir: directory, env, commands: { claude: command }, allowAccountChange: options.allowAccountChange, execution: options.execution, nativeTools: options.nativeTools, commandWrapper: options.commandWrapper },
371
385
  limits: { admissionTimeoutMs: 150, ...options.limits } },
372
386
  async trace() { return (await readFile(trace, "utf8")).trim().split("\n").map(line => JSON.parse(line)); } };
373
387
  }
@@ -487,6 +501,10 @@ test("native Send retries inspect the durable prompt receipt before duplicate ha
487
501
  assert.equal(Object.hasOwn((await f.conversation.read()).pendingRequest, "message"), false);
488
502
  await f.first.close();
489
503
  const resumed = await f.runtime().open({ id: "conversation" });
504
+ const uncertain = await resumed.read();
505
+ assert.equal(uncertain.status, "unconfirmed");
506
+ assert.equal(uncertain.pendingRequest.messageId, request.messageId);
507
+ assert.match(uncertain.error, /previous submission was interrupted/);
490
508
  assert.equal((await resumed.send(request)).status, "accepted");
491
509
  const recovered = await resumed.wait();
492
510
  assert.equal(recovered.status, "ready");
@@ -496,6 +514,33 @@ test("native Send retries inspect the durable prompt receipt before duplicate ha
496
514
  assert.equal((await resumed.send(request)).duplicate, true);
497
515
  });
498
516
 
517
+ test("reopening an undispatched Claude reservation does not invent an uncertain-delivery warning", async t => {
518
+ const f = await fixture(t, { environment: { TEST_REJECT_EFFORT: "low" } });
519
+ await f.conversation.configure({ effort: "low" });
520
+ const request = { messageId: "never-dispatched", text: "Keep this original request", origin: "application" };
521
+ await assert.rejects(f.conversation.send(request), /Settings rejected/);
522
+ await f.conversation.wait();
523
+ const readRequest = () => f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.request);
524
+ const saved = await readRequest();
525
+ assert.equal(saved.attempted, false);
526
+ assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 0);
527
+ await f.first.close();
528
+ const resumed = await f.runtime().open({ id: "conversation" });
529
+ const state = await resumed.read();
530
+ assert.equal(state.status, "ready");
531
+ assert.equal(state.pendingRequest, null);
532
+ assert.equal(state.error, "");
533
+ assert.equal(state.conversationLog.length, 0);
534
+ assert.deepEqual(await readRequest(), { ...saved,
535
+ error: "The previous submission was interrupted. Check delivery before submitting it again." },
536
+ "Projection must retain the original reservation, including its dispatch evidence");
537
+ await resumed.configure({ effort: "high" });
538
+ await resumed.send(request);
539
+ await resumed.wait();
540
+ assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1,
541
+ "Only an explicit send dispatches the retained request");
542
+ });
543
+
499
544
  test("native changeover keeps the prepared prompt frozen across rejection, transcript edits and retry", async t => {
500
545
  const f = await fixture(t, { environment: { TEST_REJECT_EFFORT: "low" } });
501
546
  await f.conversation.send(input);
@@ -593,6 +638,9 @@ test("cancel drains native work and allows a subsequent request in the same conv
593
638
  assert.equal((await f.conversation.read()).status, "working");
594
639
  assert.deepEqual(await f.conversation.cancel(), { stopped: true });
595
640
  assert.equal((await f.conversation.read()).conversationLog[0].metadata.runtime.status, "cancelled");
641
+ await assert.rejects(f.conversation.send({ messageId: "late-after-stop", text: "Do more", steer: true }), {
642
+ code: "conversation_not_steerable", delivery: "not-sent"
643
+ });
596
644
  const binding = await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding);
597
645
  assert.equal(binding.executionId, "");
598
646
  await f.conversation.send(input);
@@ -759,6 +807,53 @@ test("changed native login cannot continue another account's conversation", asyn
759
807
  assert.equal((await f.conversation.read()).conversationLog.length, 1);
760
808
  });
761
809
 
810
+ test("workspace-authorized Claude account changes retire the old process and resume native history", async t => {
811
+ const f = await fixture(t, { allowAccountChange: true });
812
+ await f.conversation.send(input);
813
+ await f.conversation.wait();
814
+ const before = await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding);
815
+ await writeFile(f.account, "different@example.test");
816
+ await f.conversation.send({ messageId: "second", text: "Continue with the current account" });
817
+ const state = await f.conversation.wait();
818
+ const after = await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding);
819
+ const starts = (await f.trace()).filter(row => row.args?.includes("--print"));
820
+ assert.equal(starts.length, 2, "The old account's process cannot be reused");
821
+ assert.equal(starts[1].args[starts[1].args.indexOf("--resume") + 1], before.conversationId);
822
+ assert.equal(after.conversationId, before.conversationId);
823
+ assert.notEqual(after.accountIdentity, before.accountIdentity);
824
+ assert.equal(state.conversationLog.length, 2);
825
+ assert.equal(state.conversationLog[0].assistant.text, "Answer: Hello");
826
+ assert.equal(state.conversationLog[1].assistant.text, "Answer: Continue with the current account");
827
+ });
828
+
829
+ test("workspace-authorized Claude account changes cannot promote an account before verified stop", async t => {
830
+ const local = createLocalConversationExecution();
831
+ const nativeIds = new Set();
832
+ let allowStop = true;
833
+ const execution = {
834
+ async start(options) {
835
+ const native = await local.start(options);
836
+ if (options.args.includes("--print")) nativeIds.add(native.id);
837
+ return native;
838
+ },
839
+ stop: id => !allowStop && nativeIds.has(id) ? Promise.resolve({ scopeEmpty: false }) : local.stop(id)
840
+ };
841
+ const f = await fixture(t, { execution, allowAccountChange: true });
842
+ await f.conversation.send(input);
843
+ await f.conversation.wait();
844
+ const before = await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding);
845
+ await writeFile(f.account, "different@example.test");
846
+ allowStop = false;
847
+ try {
848
+ await assert.rejects(f.conversation.send({ messageId: "second", text: "Continue" }), /cleanup could not be confirmed/);
849
+ assert.deepEqual(await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding), before);
850
+ assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
851
+ } finally { allowStop = true; }
852
+ assert.deepEqual(await f.conversation.cancel(), { stopped: true });
853
+ await f.conversation.send({ messageId: "second", text: "Continue" });
854
+ assert.equal((await f.conversation.wait()).conversationLog[1].assistant.text, "Answer: Continue");
855
+ });
856
+
762
857
  // Ported from the production provider's control-order case. The common fixture
763
858
  // observes native JSON controls instead of the application's provider wrapper.
764
859
  test("Claude changes provider controls before model and keeps the native conversation", async t => {
@@ -1081,8 +1176,15 @@ test("Claude accepts a goal during an active turn through the existing steering
1081
1176
  test("Claude rejects reserved goals and waits for ordinary Send to deliver a replacement briefing", async t => {
1082
1177
  const f = await fixture(t);
1083
1178
  let state = await f.conversation.read();
1084
- await assert.rejects(f.conversation.updateGoal({ action: "set", objective: "clear", messageId: "reserved",
1085
- expectedSegmentId: state.segmentId }), /goal condition/u);
1179
+ for (const objective of ["clear", "stop", "off", "reset", "none", "cancel"]) {
1180
+ await assert.rejects(f.conversation.updateGoal({ action: "set", objective, messageId: `reserved-${objective}`,
1181
+ expectedSegmentId: state.segmentId }), {
1182
+ code: "conversation_invalid_goal", statusCode: 400, delivery: "not-sent"
1183
+ });
1184
+ }
1185
+ assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 0,
1186
+ "reserved conditions are rejected before native dispatch, not uncertain sends");
1187
+ assert.equal((await f.conversation.read()).conversationLog.length, 0);
1086
1188
  await f.conversation.send(input);
1087
1189
  state = await f.conversation.wait();
1088
1190
  await f.conversation.replace({ operationId: "renew-goal", reason: "renewal", expectedSegmentId: state.segmentId });
@@ -1806,6 +1908,20 @@ test("completed Claude external account changes refuse repeatedly without finger
1806
1908
  } finally { await f.close(); }
1807
1909
  });
1808
1910
 
1911
+ test("workspace-authorized account adoption never rebinds a completed Claude receipt", async t => {
1912
+ const f = await completedEnvelopeFixture(t, { allowAccountChange: true });
1913
+ try {
1914
+ const receipt = await f.complete();
1915
+ const before = await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding);
1916
+ await writeFile(f.account, "different@example.test");
1917
+ for (let attempt = 0; attempt < 2; attempt++) {
1918
+ await assert.rejects(f.prepare(receipt), /different account or provider connection/);
1919
+ assert.deepEqual(await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding), before);
1920
+ }
1921
+ assert.equal(f.effects(), 0);
1922
+ } finally { await f.close(); }
1923
+ });
1924
+
1809
1925
  test("completed Claude account inspection never creates a missing historical fingerprint", async t => {
1810
1926
  const f = await completedEnvelopeFixture(t);
1811
1927
  try {
@@ -303,6 +303,42 @@ test("Claude observes parent exit after initialization even while a child retain
303
303
  assert.equal(failures, 1);
304
304
  });
305
305
 
306
+ test("Claude host startup budget allows slow initialization and still bounds failed startup", async t => {
307
+ t.mock.timers.enable({ apis: ["setTimeout"] });
308
+ const { PassThrough, Writable } = await import("node:stream");
309
+ for (const responds of [true, false]) {
310
+ const requested = Promise.withResolvers();
311
+ const stdout = new PassThrough();
312
+ const stdin = new Writable({ write(chunk, _encoding, done) {
313
+ requested.resolve(JSON.parse(chunk.toString())); done();
314
+ } });
315
+ const stops = [];
316
+ const pending = createClaudeCodeProcess({ initializationTimeoutMs: 90_000, execution: {
317
+ async start() { return { id: "cold-start", stdin, stdout }; },
318
+ async stop(id) { stops.push(id); return { scopeEmpty: true }; }
319
+ } });
320
+ let settled = false;
321
+ void pending.then(() => { settled = true; }, () => { settled = true; });
322
+ const frame = await requested.promise;
323
+ t.mock.timers.tick(45_000);
324
+ await new Promise(resolve => setImmediate(resolve));
325
+ assert.equal(settled, false, "Cold initialization survives the ordinary control deadline");
326
+ if (responds) {
327
+ stdout.write(JSON.stringify({ type: "control_response", response: {
328
+ subtype: "success", request_id: frame.request_id, response: {}
329
+ } }) + "\n");
330
+ await (await pending).stop();
331
+ } else {
332
+ const rejected = assert.rejects(pending, error =>
333
+ error.code === "assistant_claude_control_timeout" && error.stopProof.scopeEmpty === true);
334
+ t.mock.timers.tick(45_000);
335
+ await rejected;
336
+ }
337
+ assert.deepEqual(stops, ["cold-start"]);
338
+ stdout.destroy(); stdin.destroy();
339
+ }
340
+ });
341
+
306
342
  test("Claude initialization rejects confirmed execution exit before half-open protocol EOF", async () => {
307
343
  const { PassThrough, Writable } = await import("node:stream");
308
344
  for (const cleanup of ["verified", "unconfirmed", "rejected"]) {
@@ -440,6 +476,31 @@ test("Claude account queries isolate concurrent identities and deduplicate each
440
476
  }
441
477
  });
442
478
 
479
+ test("Claude account queries label temporary startup without changing cached process reuse", async () => {
480
+ const starts = [];
481
+ let stops = 0;
482
+ const queries = createClaudeAccountQueries({
483
+ accountIdentity: async () => "private-account-identity",
484
+ createProcess(input) {
485
+ starts.push(input);
486
+ return {
487
+ initialization: { models: [] },
488
+ client: { request: async () => ({ rate_limits_available: false }) },
489
+ async stop() { stops++; return { scopeEmpty: true }; }
490
+ };
491
+ }
492
+ });
493
+ for (const method of ["readCatalogue", "readPlanUsage"]) {
494
+ await queries[method]({});
495
+ await queries[method]({});
496
+ }
497
+ assert.deepEqual(starts.map(input => input.purpose), ["catalogue", "usage"]);
498
+ assert.ok(starts.every(input => input.signal instanceof AbortSignal));
499
+ assert.ok(starts.every(input => Object.keys(input).sort().join() === "purpose,signal"));
500
+ assert.equal(stops, 2);
501
+ await queries.invalidate();
502
+ });
503
+
443
504
  test("account invalidation cancels held startup and usage without stopping a borrowed conversation", async () => {
444
505
  for (const phase of ["startup", "owned usage", "borrowed usage"]) {
445
506
  const started = Promise.withResolvers();