@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.
- package/examples/conversation/package.json +1 -1
- package/fixtures/responsive-assistant/ConversationFixture.vue +5 -3
- package/fixtures/responsive-assistant/DeliveryFixture.vue +9 -0
- package/package.json +1 -1
- package/src/client/conversation/AssistantConversationElement.vue +7 -4
- package/src/client/conversation/AssistantGoalControl.vue +31 -17
- package/src/client/conversation/AssistantTranscript.vue +14 -7
- package/src/client/conversation/useAssistantAttachments.js +6 -3
- package/src/server/conversation/claudeClient.js +22 -5
- package/src/server/conversation/claudeHistory.js +62 -5
- package/src/server/conversation/claudeProcess.js +7 -6
- package/src/server/conversation/claudeTurn.js +284 -57
- package/src/server/conversation/codexConfiguration.js +24 -1
- package/src/server/conversation/codexGoalCommands.js +8 -3
- package/src/server/conversation/codexTurnRecovery.js +18 -8
- package/src/server/conversation/continuity.js +54 -17
- package/src/server/conversation/providers/claudeDriver.js +6 -5
- package/src/server/conversation/providers/codexDriver.js +3 -3
- package/src/server/conversation/providers/scoped.js +31 -10
- package/src/server/conversation/runtime.js +48 -13
- package/src/server/conversation/streams.js +7 -3
- package/src/server/conversation/transcript.js +2 -1
- package/src/server/hosts/codexConfiguration.js +1 -0
- package/src/shared/conversation/longTextBlocks.js +0 -4
- package/test/assistantScroll.browser.test.js +76 -3
- package/test/claudeClient.test.js +73 -0
- package/test/claudeConversationSession.test.js +120 -4
- package/test/claudeProcess.test.js +61 -0
- package/test/claudeTurn.test.js +1326 -10
- package/test/codexConversationSession.test.js +9 -1
- package/test/codexGoal.test.js +47 -0
- package/test/codexModelCatalog.test.js +55 -0
- package/test/codexTurn.test.js +228 -0
- package/test/conversationAttachments.test.js +17 -0
- package/test/conversationRuntime.test.js +40 -0
- package/test/conversationStreaming.test.js +18 -0
- 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
|
|
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", "
|
|
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 +
|
|
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
|
-
|
|
1085
|
-
|
|
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();
|