@jskit-ai/assistant-core 0.1.205 → 0.1.206

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 (39) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/App.vue +12 -2
  3. package/package.json +19 -9
  4. package/src/client/conversation/AssistantTranscript.vue +177 -16
  5. package/src/server/conversation/claudeTurn.js +16 -7
  6. package/src/server/conversation/codexConversations.js +94 -0
  7. package/src/server/conversation/codexProvider.js +15 -5
  8. package/src/server/conversation/codexProviderOwner.js +70 -47
  9. package/src/server/conversation/codexProviderSelection.js +5 -2
  10. package/src/server/conversation/codexRuntimeLifecycle.js +2 -1
  11. package/src/server/conversation/codexTurn.js +10 -1
  12. package/src/server/conversation/codexTurnControl.js +1 -1
  13. package/src/server/conversation/codexTurnRecovery.js +21 -8
  14. package/src/server/conversation/openCodeRuntime.js +107 -64
  15. package/src/server/conversation/openCodeTurn.js +33 -16
  16. package/src/server/conversation/providers/api.js +1 -0
  17. package/src/server/conversation/providers/claudeDriver.js +13 -0
  18. package/src/server/conversation/providers/codex.js +90 -47
  19. package/src/server/conversation/providers/codexDriver.js +4 -1
  20. package/src/server/conversation/providers/opencodeDriver.js +3 -0
  21. package/src/server/conversation/runtime.js +32 -5
  22. package/src/server/hosts/codexEvents.js +15 -0
  23. package/src/server/hosts/codexTurn.js +5 -0
  24. package/src/server/hosts/openCodeClient.js +4 -0
  25. package/src/server/lib/aiConnectionClient.js +9 -11
  26. package/test/aiConnectionClient.test.js +209 -0
  27. package/test/assistantScroll.browser.test.js +255 -1
  28. package/test/claudeConversationSession.test.js +78 -0
  29. package/test/claudeTurn.test.js +18 -0
  30. package/test/codexConversationSession.test.js +318 -0
  31. package/test/codexTurn.test.js +203 -2
  32. package/test/conversationConfiguration.test.js +27 -0
  33. package/test/conversationReplacement.test.js +76 -1
  34. package/test/conversationRuntime.test.js +173 -4
  35. package/test/conversationSession.test.js +139 -0
  36. package/test/conversationTools.test.js +17 -3
  37. package/test/opencodeConversationSession.test.js +200 -1
  38. package/test/packageMetadata.test.js +75 -0
  39. package/test/serviceToolCatalog.test.js +31 -0
@@ -159,3 +159,212 @@ test("authorized image bytes use the selected provider protocol", async () => {
159
159
  ] }] }));
160
160
  assert.equal(chunks[0].choices[0].delta.content, "An image");
161
161
  });
162
+
163
+ test("SDK streaming rejects its first provider failure before EOF without exposing later output", { timeout: 5000 }, async t => {
164
+ const source = new TransformStream();
165
+ const writer = source.writable.getWriter();
166
+ t.after(() => writer.abort().catch(() => {}));
167
+ let requests = 0;
168
+ const client = createAiConnectionClient(await publicConnection(), {
169
+ async fetch(url, options) {
170
+ requests++;
171
+ assert.equal(String(url), "https://opencode.ai/zen/v1/chat/completions");
172
+ assert.equal(new Headers(options.headers).get("authorization"), "Bearer public");
173
+ assert.equal(JSON.parse(options.body).tools[0].function.name, "lookup");
174
+ return new Response(source.readable, { headers: { "content-type": "text/event-stream" } });
175
+ }
176
+ });
177
+ const tools = [{ type: "function", function: { name: "lookup", parameters: {
178
+ type: "object", properties: { query: { type: "string" } }, required: ["query"]
179
+ } } }];
180
+ const iterator = client.createChatCompletionStream({ messages: [{ role: "user", content: "Find records" }], tools });
181
+ const send = data => writer.write(new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`));
182
+ const first = iterator.next();
183
+ await send({ choices: [{ index: 0, delta: { content: "Before failure" } }] });
184
+ assert.deepEqual((await first).value, { choices: [{ delta: { content: "Before failure" } }] });
185
+ let settled = false;
186
+ const ending = iterator.next().then(
187
+ result => { settled = true; return { result }; },
188
+ error => { settled = true; return { error }; }
189
+ );
190
+ const firstFailure = { message: "First provider failure", code: "FIRST_FAILURE" };
191
+ await send({ error: firstFailure });
192
+ await new Promise(resolve => setImmediate(resolve));
193
+ assert.equal(settled, true, "the original provider failure rejects before any late output or transport EOF");
194
+ await send({ choices: [{ index: 0, delta: { content: "Must not be exposed" } }] });
195
+ await send({ choices: [{ index: 0, delta: { tool_calls: [
196
+ { index: 0, id: "late_call", type: "function", function: { name: "lookup", arguments: '{"query":"records"}' } }
197
+ ] } }] });
198
+ await send({ error: { message: "Later provider failure", code: "LATER_FAILURE" } });
199
+ await send({ choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
200
+ usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 } });
201
+ await new Promise(resolve => setImmediate(resolve));
202
+ assert.equal(settled, true, "the original provider failure rejects before transport EOF");
203
+ await writer.close();
204
+ const outcome = await ending;
205
+ assert.equal(outcome.error?.name, "AI_StreamProviderError");
206
+ assert.equal(outcome.error.message, firstFailure.message);
207
+ assert.equal(outcome.error.code, firstFailure.code);
208
+ assert.deepEqual(outcome.error.data, firstFailure, "a later provider error must not replace the first failure");
209
+ assert.equal(outcome.result, undefined, "late text, tool calls, finish and usage never become a yielded chunk");
210
+ assert.equal((await iterator.next()).done, true);
211
+ assert.equal(requests, 1, "draining a failed stream must not retry or start a tool round");
212
+ });
213
+
214
+ test("SDK streaming rejects the first invalid tool input without yielding later calls or completion", { timeout: 5000 }, async t => {
215
+ const source = new TransformStream();
216
+ const writer = source.writable.getWriter();
217
+ t.after(() => writer.abort().catch(() => {}));
218
+ let requests = 0;
219
+ const client = createAiConnectionClient(await publicConnection(), {
220
+ async fetch() {
221
+ requests++;
222
+ return new Response(source.readable, { headers: { "content-type": "text/event-stream" } });
223
+ }
224
+ });
225
+ const tools = [{ type: "function", function: { name: "lookup", parameters: {
226
+ type: "object", properties: { query: { type: "string" } }, required: ["query"]
227
+ } } }];
228
+ const iterator = client.createChatCompletionStream({ messages: [{ role: "user", content: "Find records" }], tools });
229
+ const send = data => writer.write(new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`));
230
+ const first = iterator.next();
231
+ await send({ choices: [{ index: 0, delta: { content: "Before tool validation" } }] });
232
+ assert.equal((await first).value.choices[0].delta.content, "Before tool validation");
233
+ const ending = iterator.next().then(result => ({ result }), error => ({ error }));
234
+ // The compatible adapter flushes these calls at EOF; malformed JSON reaches
235
+ // the SDK's invalid-input branch rather than relying on schema validation.
236
+ await send({ choices: [{ index: 0, delta: { tool_calls: [
237
+ { index: 0, id: "invalid_first", type: "function", function: { name: "lookup", arguments: '{"query":}' } },
238
+ { index: 1, id: "invalid_later", type: "function", function: { name: "lookup", arguments: '{"later":}' } },
239
+ { index: 2, id: "valid_later", type: "function", function: { name: "lookup", arguments: '{"query":"records"}' } }
240
+ ] }, finish_reason: "tool_calls" }], usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 } });
241
+ await writer.close();
242
+ const outcome = await ending;
243
+ assert.equal(outcome.error?.name, "AI_InvalidToolInputError");
244
+ assert.equal(outcome.error.toolName, "lookup");
245
+ assert.equal(outcome.error.toolInput, '{"query":}', "the second invalid call cannot replace the first error");
246
+ assert.equal(outcome.result, undefined, "invalid and later valid calls, finish and usage remain unexposed");
247
+ assert.equal((await iterator.next()).done, true);
248
+ assert.equal(requests, 1);
249
+ });
250
+
251
+ test("SDK cancellation after its first text retains the original reason and suppresses later output", { timeout: 5000 }, async t => {
252
+ const source = new TransformStream();
253
+ const writer = source.writable.getWriter();
254
+ t.after(() => writer.abort().catch(() => {}));
255
+ const abort = new AbortController();
256
+ const reason = new Error("Stopped after the first text");
257
+ let requestSignal;
258
+ let requests = 0;
259
+ const client = createAiConnectionClient(await publicConnection(), {
260
+ async fetch(_url, options) {
261
+ requests++;
262
+ requestSignal = options.signal;
263
+ return new Response(source.readable, { headers: { "content-type": "text/event-stream" } });
264
+ }
265
+ });
266
+ const iterator = client.createChatCompletionStream({ messages: [{ role: "user", content: "Hello" }], signal: abort.signal });
267
+ const send = data => writer.write(new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`));
268
+ const first = iterator.next();
269
+ await send({ choices: [{ index: 0, delta: { content: "First text" } }] });
270
+ assert.equal((await first).value.choices[0].delta.content, "First text");
271
+ const ending = iterator.next().then(result => ({ result }), error => ({ error }));
272
+ abort.abort(reason);
273
+ assert.equal(requestSignal.aborted, true);
274
+ assert.equal(requestSignal.reason, reason);
275
+ // Fetch remains controlled: let the real SDK observe abort while consuming
276
+ // the response, then finish the transport instead of replacing its runner.
277
+ await send({ choices: [{ index: 0, delta: { content: "Late text" } }] });
278
+ await send({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
279
+ usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 } });
280
+ await writer.close();
281
+ const outcome = await ending;
282
+ assert.equal(outcome.error, reason);
283
+ assert.equal(outcome.result, undefined, "abort after text cannot publish later text, finish or usage");
284
+ assert.equal((await iterator.next()).done, true);
285
+ assert.equal(requests, 1);
286
+ });
287
+
288
+ test("SDK streaming retains its immediate provider failure when the later transport fails", { timeout: 5000 }, async t => {
289
+ const source = new TransformStream();
290
+ const writer = source.writable.getWriter();
291
+ t.after(() => writer.abort().catch(() => {}));
292
+ let requests = 0;
293
+ const client = createAiConnectionClient(await publicConnection(), {
294
+ async fetch() {
295
+ requests++;
296
+ return new Response(source.readable, { headers: { "content-type": "text/event-stream" } });
297
+ }
298
+ });
299
+ const iterator = client.createChatCompletionStream({ messages: [{ role: "user", content: "Hello" }] });
300
+ const send = data => writer.write(new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`));
301
+ const first = iterator.next();
302
+ await send({ choices: [{ index: 0, delta: { content: "Before failure" } }] });
303
+ assert.equal((await first).value.choices[0].delta.content, "Before failure");
304
+ let settled = false;
305
+ const ending = iterator.next().then(
306
+ result => { settled = true; return { result }; },
307
+ error => { settled = true; return { error }; }
308
+ );
309
+ const firstFailure = { message: "First provider failure", code: "FIRST_FAILURE" };
310
+ await send({ error: firstFailure });
311
+ await new Promise(resolve => setImmediate(resolve));
312
+ assert.equal(settled, true, "the first failure rejects before the later transport error");
313
+ await send({ choices: [{ index: 0, delta: { content: "Late text after rejection" } }] });
314
+ await new Promise(resolve => setImmediate(resolve));
315
+ assert.equal(settled, true, "the original provider failure rejects without waiting for later transport failure");
316
+ const lateTransportError = new Error("Late transport drain failure");
317
+ await writer.abort(lateTransportError);
318
+ const outcome = await ending;
319
+ assert.notEqual(outcome.error, lateTransportError, "transport drain failure must not replace the observed provider failure");
320
+ assert.equal(outcome.error?.name, "AI_StreamProviderError");
321
+ assert.equal(outcome.error.message, firstFailure.message);
322
+ assert.equal(outcome.error.code, firstFailure.code);
323
+ assert.deepEqual(outcome.error.data, firstFailure);
324
+ assert.equal(outcome.result, undefined, "late text and transport failure do not publish another chunk");
325
+ assert.equal((await iterator.next()).done, true);
326
+ assert.equal(requests, 1);
327
+ });
328
+
329
+
330
+ test("only explicit application-catalogue mode reports well-formed unavailable tool calls", async () => {
331
+ const toolCall = argumentsText => ({ choices: [{ index: 0, delta: { tool_calls: [
332
+ { index: 0, id: "unavailable-call", type: "function", function: { name: "unavailable_action", arguments: argumentsText } }
333
+ ] }, finish_reason: "tool_calls" }] });
334
+ const connection = await publicConnection();
335
+ for (const [reportUnavailableToolCalls, argumentsText] of [
336
+ [false, '{"value":"must not execute"}'], [true, '{"value":}'], [true, 'null'], [true, '[]'], [true, '"text"']
337
+ ]) {
338
+ let requests = 0;
339
+ const client = createAiConnectionClient(connection, { reportUnavailableToolCalls,
340
+ async fetch() { requests++; return eventsResponse([toolCall(argumentsText)]); }
341
+ });
342
+ await assert.rejects(Array.fromAsync(client.createChatCompletionStream({
343
+ messages: [{ role: "user", content: "Check without changing anything" }], tools: []
344
+ })), error => error.name === "AI_NoSuchToolError" && error.toolName === "unavailable_action");
345
+ assert.equal(requests, 1, "unavailable validation must not trigger an inference retry");
346
+ }
347
+ const invalidKnown = createAiConnectionClient(connection, { reportUnavailableToolCalls: true,
348
+ async fetch() { return eventsResponse([{ choices: [{ index: 0, delta: { tool_calls: [
349
+ { index: 0, id: "invalid-known", type: "function", function: { name: "lookup", arguments: '{"query":}' } }
350
+ ] }, finish_reason: "tool_calls" }] }]); }
351
+ });
352
+ await assert.rejects(Array.fromAsync(invalidKnown.createChatCompletionStream({
353
+ messages: [{ role: "user", content: "Find records" }],
354
+ tools: [{ type: "function", function: { name: "lookup", parameters: {
355
+ type: "object", properties: { query: { type: "string" } }, required: ["query"]
356
+ } } }]
357
+ })), error => error.name === "AI_InvalidToolInputError" && error.toolName === "lookup");
358
+ let requests = 0;
359
+ const client = createAiConnectionClient(connection, { reportUnavailableToolCalls: true,
360
+ async fetch() { requests++; return eventsResponse([toolCall('{"value":"must not execute"}')]); }
361
+ });
362
+ const chunks = await Array.fromAsync(client.createChatCompletionStream({
363
+ messages: [{ role: "user", content: "Check without changing anything" }], tools: []
364
+ }));
365
+ assert.deepEqual(chunks.flatMap(chunk => chunk.choices[0].delta.tool_calls || []), [{
366
+ index: 0, id: "unavailable-call", function: { name: "unavailable_action", arguments: '{"value":"must not execute"}' }
367
+ }]);
368
+ assert.equal(chunks.at(-1).choices[0].finish_reason, "tool-calls");
369
+ assert.equal(requests, 1, "reporting the attempted call neither executes an action nor starts another request");
370
+ });
@@ -405,7 +405,10 @@ test("upward scrolling loads older history once and preserves retry and selectio
405
405
  await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
406
406
  await expect(requests).toHaveText("History requests: 3");
407
407
  await external("Exhaust history");
408
- await body.focus();
408
+ await expect.poll(async () => {
409
+ await body.focus();
410
+ return body.evaluate(element => document.activeElement === element);
411
+ }).toBe(true);
409
412
  await body.press("Home");
410
413
  await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(0);
411
414
  await expect(requests).toHaveText("History requests: 3");
@@ -420,6 +423,206 @@ test("upward scrolling loads older history once and preserves retry and selectio
420
423
  }
421
424
  });
422
425
 
426
+ test("retained hidden transcripts preserve readers and follow only at the latest message", {
427
+ skip: !RUN_BROWSER_TEST,
428
+ timeout: 90_000
429
+ }, async () => {
430
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
431
+ const browser = await chromium.launch(createChromiumLaunchOptions());
432
+ try {
433
+ for (const width of [390, 800, 1365]) {
434
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
435
+ await page.goto(`${vite.baseURL}/?controls=1&history=1&retain-hidden=1&initial-hidden=1`);
436
+ const body = page.locator(".assistant-transcript__body");
437
+ const external = name => page.getByRole("button", { name, exact: true }).evaluate(element => element.click());
438
+ await expect(body).toBeAttached();
439
+ await expect(body).toBeHidden();
440
+ await external("Toggle visibility");
441
+ await expect(body).toBeVisible();
442
+ await expect(page.locator(".assistant-transcript__settling")).toHaveCount(0);
443
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
444
+ const draft = page.getByRole("textbox", { name: "Message AI assistant", exact: true });
445
+ await draft.fill("Keep my typed draft");
446
+ await body.hover();
447
+ await page.mouse.wheel(0, -1000);
448
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeGreaterThan(50);
449
+ await body.evaluate(element => { element.scrollTop = 420; });
450
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
451
+ await body.evaluate(element => {
452
+ window.retainedBody = element;
453
+ const top = element.getBoundingClientRect().top;
454
+ const anchor = [...element.querySelectorAll(".assistant-transcript__turn")].find(turn => turn.getBoundingClientRect().bottom > top);
455
+ window.retainedAnchor = { element: anchor, offset: anchor.getBoundingClientRect().top - top };
456
+ });
457
+ await external("Toggle visibility");
458
+ await expect(body).toBeHidden();
459
+ await external("Append reply");
460
+ // Hidden zero-geometry events must not change following or fetch history.
461
+ await body.evaluate(element => element.dispatchEvent(new Event("scroll")));
462
+ await expect(page.locator("output")).toHaveText("History requests: 0");
463
+ await external("Toggle visibility");
464
+ await expect(body).toBeVisible();
465
+ assert.equal(await body.evaluate(element => element === window.retainedBody), true);
466
+ await expect.poll(() => body.evaluate(element => Math.abs(window.retainedAnchor.element.getBoundingClientRect().top - element.getBoundingClientRect().top - window.retainedAnchor.offset))).toBeLessThan(2);
467
+ await expect(draft).toHaveValue("Keep my typed draft");
468
+ await body.hover();
469
+ await page.mouse.wheel(0, 100_000);
470
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
471
+ await external("Toggle visibility");
472
+ await external("Append reply");
473
+ await external("Toggle visibility");
474
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
475
+ await body.hover();
476
+ await page.mouse.wheel(0, -1000);
477
+ await external("Toggle visibility");
478
+ await external("Change conversation");
479
+ await external("Toggle visibility");
480
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
481
+ await expect(page.locator(".assistant-transcript__settling")).toHaveCount(0);
482
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
483
+ await page.close();
484
+ }
485
+ } finally {
486
+ await browser.close();
487
+ await stopProcess(vite);
488
+ }
489
+ });
490
+
491
+ test("transient update errors keep the loaded transcript, reader, draft and focus in a compact banner", {
492
+ skip: !RUN_BROWSER_TEST,
493
+ timeout: 90_000
494
+ }, async () => {
495
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
496
+ const browser = await chromium.launch(createChromiumLaunchOptions());
497
+ try {
498
+ for (const width of [390, 800, 1365]) {
499
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
500
+ await page.goto(`${vite.baseURL}/?controls=1&history=1&update-errors=1`);
501
+ const body = page.locator(".assistant-transcript__body");
502
+ const draft = page.getByRole("textbox", { name: "Message AI assistant", exact: true });
503
+ const external = name => page.getByRole("button", { name, exact: true }).evaluate(element => element.click());
504
+ await expect(page.locator(".assistant-transcript__settling")).toHaveCount(0);
505
+ await draft.fill("Keep my draft while updates reconnect.");
506
+ await body.hover();
507
+ await page.mouse.wheel(0, -1000);
508
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeGreaterThan(50);
509
+ await body.evaluate(element => { element.scrollTop = 420; window.updateErrorBody = element; });
510
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
511
+ await draft.evaluate(element => {
512
+ element.focus({ preventScroll: true });
513
+ element.setSelectionRange(2, 7);
514
+ window.updateErrorDraft = element;
515
+ });
516
+ const rows = await body.locator(".assistant-transcript__turn").count();
517
+ await external("Show update error");
518
+ const banner = page.locator(".assistant-transcript__error");
519
+ await expect(banner).toContainText("Conversation updates are temporarily unavailable. Reload to reconnect; the assistant may still be working.");
520
+ await expect(banner.getByRole("button", { name: "Reload chat", exact: true })).toBeVisible();
521
+ await expect(body).toBeVisible();
522
+ assert.equal(await body.evaluate(element => element === window.updateErrorBody), true);
523
+ await expect(body.locator(".assistant-transcript__turn")).toHaveCount(rows);
524
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
525
+ await expect(draft).toHaveValue("Keep my draft while updates reconnect.");
526
+ await expect(draft).toBeFocused();
527
+ assert.deepEqual(await draft.evaluate(element => [element === window.updateErrorDraft, element.selectionStart, element.selectionEnd]), [true, 2, 7]);
528
+ const transcriptBox = await page.locator(".assistant-transcript").boundingBox();
529
+ const bannerBox = await banner.boundingBox();
530
+ const bodyBox = await body.boundingBox();
531
+ assert.ok(bannerBox.height < transcriptBox.height / 3);
532
+ assert.ok(bodyBox.height > transcriptBox.height / 2);
533
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
534
+ await banner.getByRole("button", { name: "Reload chat", exact: true }).click();
535
+ await expect(banner).toHaveCount(0);
536
+ await expect(page.locator(".assistant-conversation")).toHaveAttribute("data-error-reloads", "1");
537
+ assert.equal(await body.evaluate(element => element === window.updateErrorBody), true);
538
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
539
+ await expect(draft).toHaveValue("Keep my draft while updates reconnect.");
540
+ await expect(page.locator("output")).toHaveText("History requests: 0");
541
+ await body.hover();
542
+ await page.mouse.wheel(0, 100_000);
543
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
544
+ await external("Show update error");
545
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
546
+ await banner.getByRole("button", { name: "Reload chat", exact: true }).click();
547
+ await expect(banner).toHaveCount(0);
548
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
549
+ await expect(page.locator(".assistant-conversation")).toHaveAttribute("data-error-reloads", "2");
550
+ await page.close();
551
+ }
552
+ } finally {
553
+ await browser.close();
554
+ await stopProcess(vite);
555
+ }
556
+ });
557
+
558
+ test("retained hidden history loads restore their original anchor and invalidate retired requests", {
559
+ skip: !RUN_BROWSER_TEST,
560
+ timeout: 90_000
561
+ }, async () => {
562
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
563
+ const browser = await chromium.launch(createChromiumLaunchOptions());
564
+ try {
565
+ for (const width of [390, 800, 1365]) {
566
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
567
+ const body = page.locator(".assistant-transcript__body");
568
+ const external = name => page.getByRole("button", { name, exact: true }).evaluate(element => element.click());
569
+ const load = async () => {
570
+ await page.goto(`${vite.baseURL}/?controls=1&history=1&paged-history=1&retain-hidden=1`);
571
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
572
+ await body.evaluate(element => {
573
+ element.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1000 }));
574
+ element.scrollTop = 0;
575
+ element.dispatchEvent(new Event("scroll", { bubbles: true }));
576
+ });
577
+ await expect(page.getByRole("button", { name: "Loading older messages…", exact: true })).toBeDisabled();
578
+ };
579
+ await load();
580
+ const anchor = page.getByText("user message 21.", { exact: false });
581
+ const before = (await anchor.boundingBox()).y;
582
+ await body.evaluate(element => { window.retainedBody = element; });
583
+ await external("Toggle visibility");
584
+ await external("Complete history load");
585
+ await expect(page.getByText("user message 1.", { exact: false })).toHaveCount(1);
586
+ await expect(body).toBeHidden();
587
+ await external("Toggle visibility");
588
+ assert.equal(await body.evaluate(element => element === window.retainedBody), true);
589
+ await expect(body).toBeVisible();
590
+ await expect.poll(async () => Math.abs((await anchor.boundingBox()).y - before)).toBeLessThan(2);
591
+ await expect(page.locator("output")).toHaveText("History requests: 1");
592
+
593
+ await load();
594
+ const errorScrollTop = await body.evaluate(element => element.scrollTop);
595
+ await external("Toggle visibility");
596
+ await external("Fail history load");
597
+ await external("Toggle visibility");
598
+ await expect(page.getByText("History unavailable", { exact: true })).toBeVisible();
599
+ assert.equal(await body.evaluate(element => element.scrollTop), errorScrollTop, "unchanged/error completion preserves the original scrollTop while showing recovery");
600
+ await page.getByRole("button", { name: "Load older messages", exact: true }).evaluate(element => element.click());
601
+ await expect(page.locator("output")).toHaveText("History requests: 2");
602
+ await external("Toggle visibility");
603
+ await external("Change conversation");
604
+ await external("Complete history load");
605
+ await external("Toggle visibility");
606
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
607
+
608
+ await load();
609
+ await body.evaluate(element => { window.retiredBody = element; });
610
+ await external("Toggle visibility");
611
+ await external("Toggle mount");
612
+ await external("Complete history load");
613
+ await external("Toggle mount");
614
+ await external("Toggle visibility");
615
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeLessThan(2);
616
+ assert.equal(await body.evaluate(element => element === window.retiredBody), false);
617
+ await expect(page.locator("output")).toHaveText("History requests: 1");
618
+ await page.close();
619
+ }
620
+ } finally {
621
+ await browser.close();
622
+ await stopProcess(vite);
623
+ }
624
+ });
625
+
423
626
  test("conversation composer preserves typing and reacts to external state and pane resizing", {
424
627
  skip: !RUN_BROWSER_TEST,
425
628
  timeout: 60_000
@@ -895,3 +1098,54 @@ test("unsent preview edits stay in their bubble and disappear for the canonical
895
1098
  await stopProcess(vite);
896
1099
  }
897
1100
  });
1101
+
1102
+
1103
+ test("pending history keeps following the reader when a new reply changes transcript height", {
1104
+ skip: !RUN_BROWSER_TEST,
1105
+ timeout: 90_000
1106
+ }, async () => {
1107
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
1108
+ const browser = await chromium.launch(createChromiumLaunchOptions());
1109
+ try {
1110
+ for (const width of [390, 1365]) {
1111
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
1112
+ const errors = [];
1113
+ page.on("pageerror", error => errors.push(error.message));
1114
+ await page.goto(`${vite.baseURL}/?controls=1&history=1`);
1115
+ const body = page.locator(".assistant-transcript__body");
1116
+ const requests = page.locator("output");
1117
+ const external = name => page.getByRole("button", { name, exact: true }).evaluate(element => element.click());
1118
+ await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
1119
+ await body.hover();
1120
+ await page.mouse.wheel(0, -100);
1121
+ await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeGreaterThan(50);
1122
+ await body.evaluate(element => { element.scrollTop = 420; });
1123
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
1124
+ await page.mouse.wheel(0, -324);
1125
+ const pending = page.getByRole("button", { name: "Loading older messages…", exact: true });
1126
+ await expect(pending).toBeDisabled();
1127
+ await expect(requests).toHaveText("History requests: 1");
1128
+ const height = await body.evaluate(element => element.scrollHeight);
1129
+ await external("Append reply");
1130
+ await expect.poll(() => body.evaluate(element => element.scrollHeight)).toBeGreaterThan(height);
1131
+ await expect.poll(async () => {
1132
+ await body.focus();
1133
+ return body.evaluate(element => document.activeElement === element);
1134
+ }).toBe(true);
1135
+ await body.press("Home");
1136
+ await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(0);
1137
+ const original = page.getByText("Conversation line 1:", { exact: false }).first();
1138
+ const originalTop = (await original.boundingBox()).y;
1139
+ await external("Complete history load");
1140
+ await expect(pending).toHaveCount(0);
1141
+ await expect.poll(async () => Math.abs((await original.boundingBox()).y - originalTop)).toBeLessThan(2);
1142
+ await expect(requests).toHaveText("History requests: 1");
1143
+ assert.deepEqual(errors, []);
1144
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
1145
+ await page.close();
1146
+ }
1147
+ } finally {
1148
+ await browser.close();
1149
+ await stopProcess(vite);
1150
+ }
1151
+ });
@@ -1199,3 +1199,81 @@ test("the same Claude driver reopens a verified closed conversation after retryi
1199
1199
  await local.close();
1200
1200
  }
1201
1201
  });
1202
+
1203
+
1204
+ for (const change of ["model", "effort"]) test(`Claude honors a consumer's fresh native policy for ${change} changes and restart`, async t => {
1205
+ const f = await fixture(t);
1206
+ const binding = () => f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.binding);
1207
+ await f.conversation.send(input);
1208
+ const original = await f.conversation.wait();
1209
+ const previous = await binding();
1210
+ const beforeSelection = await f.trace();
1211
+ const oldHistoryPath = path.join(previous.configRoot, "projects", previous.workdir.replace(/[^a-zA-Z0-9]/gu, "-"), `${previous.conversationId}.jsonl`);
1212
+ const oldHistory = await readFile(oldHistoryPath, "utf8");
1213
+ const selected = { ...configuration, [change]: change === "model" ? "another-model" : "low" };
1214
+ const request = { operationId: `fresh-${change}`, expectedSegmentId: original.segmentId,
1215
+ engine: "claude", configuration: selected, retireNative: true };
1216
+ const receipt = await f.conversation.select(request);
1217
+ assert.notEqual(receipt.segmentId, original.segmentId);
1218
+ const inert = await binding();
1219
+ assert.notEqual(inert.conversationId, previous.conversationId);
1220
+ assert.equal(inert.sent, false);
1221
+ assert.equal(inert.configRoot, previous.configRoot);
1222
+ assert.equal(inert.workdir, previous.workdir);
1223
+ const predecessor = await f.storage.read("conversation", async tx => (await tx.readMetadata()).runtime.predecessors.find(segment => segment.segmentId === original.segmentId));
1224
+ assert.equal(predecessor.binding.conversationId, previous.conversationId);
1225
+ assert.equal(inert.executionId, "");
1226
+ assert.deepEqual(await f.trace(), beforeSelection, "Selecting allocates an inert handle without inference");
1227
+ const retained = await f.conversation.read();
1228
+ assert.equal(retained.id, original.id);
1229
+ assert.deepEqual(retained.conversationLog, original.conversationLog);
1230
+ assert.equal((await f.conversation.select(request)).duplicate, true);
1231
+ await f.conversation.send({ messageId: "changed", text: "Continue after the change" });
1232
+ assert.equal((await f.conversation.wait()).conversationLog.at(-1).metadata.runtime.status, "complete");
1233
+ const fresh = await binding();
1234
+ assert.equal(fresh.conversationId, inert.conversationId);
1235
+ assert.equal(fresh.accountIdentity, previous.accountIdentity);
1236
+ assert.equal(await readFile(oldHistoryPath, "utf8"), oldHistory);
1237
+ const trace = await f.trace();
1238
+ const starts = trace.filter(row => row.args?.includes("--print"));
1239
+ assert.equal(starts.length, 2);
1240
+ assert.equal(starts[1].args[starts[1].args.indexOf("--session-id") + 1], inert.conversationId);
1241
+ assert.equal(starts[1].args.includes("--resume"), false);
1242
+ assert.equal(starts[1].args[starts[1].args.indexOf("--model") + 1], selected.model);
1243
+ assert.equal(trace.filter(row => row.frame?.request?.subtype === "apply_flag_settings").at(-1).frame.request.settings.effortLevel, selected.effort);
1244
+ const nativeMessages = trace.filter(row => row.frame?.type === "user");
1245
+ assert.equal(nativeMessages.length, 2);
1246
+ const handedHistory = JSON.parse(nativeMessages[1].frame.message.content.split("\n").find(line => line.startsWith('{"messages":')));
1247
+ assert.deepEqual(handedHistory.messages.map(({ role, text }) => [role, text]), [["user", "Hello"], ["assistant", "Answer: Hello"]]);
1248
+ assert.deepEqual(handedHistory.removedMessageIds, []);
1249
+ assert.equal(nativeMessages[1].frame.message.content.split("User's message:\n")[1], "Continue after the change");
1250
+ assert.match(nativeMessages[1].frame.message.content, /Hello/);
1251
+ assert.match(nativeMessages[1].frame.message.content, /Answer: Hello/);
1252
+ assert.match(nativeMessages[1].frame.message.content, /Continue after the change/);
1253
+ await f.first.close();
1254
+ const resumed = await f.runtime().open({ id: "conversation" });
1255
+ assert.equal((await resumed.select(request)).duplicate, true);
1256
+ assert.equal((await resumed.send(input)).duplicate, true);
1257
+ assert.equal((await binding()).conversationId, inert.conversationId);
1258
+ await resumed.send({ messageId: "after-restart", text: "Continue after restart" });
1259
+ assert.equal((await resumed.wait()).conversationLog.length, 3);
1260
+ const restarted = await f.trace();
1261
+ assert.equal(restarted.filter(row => row.frame?.type === "user").length, 3);
1262
+ assert.equal(restarted.filter(row => row.frame?.type === "user").at(-1).frame.message.content, "Continue after restart");
1263
+ const restart = restarted.filter(row => row.args?.includes("--print")).at(-1);
1264
+ assert.equal(restart.args[restart.args.indexOf("--resume") + 1], inert.conversationId);
1265
+ const returned = await resumed.select({ operationId: `restore-${change}`, expectedSegmentId: (await resumed.read()).segmentId,
1266
+ engine: "claude", configuration, retireNative: true });
1267
+ assert.notEqual(returned.segmentId, original.segmentId, "The consumer policy cannot restore an older retained segment");
1268
+ assert.notEqual(returned.segmentId, receipt.segmentId);
1269
+ await resumed.send({ messageId: "returned", text: "Back to the original settings" });
1270
+ assert.equal((await resumed.wait()).conversationLog.length, 4);
1271
+ const restored = await binding();
1272
+ assert.notEqual(restored.conversationId, previous.conversationId);
1273
+ assert.notEqual(restored.conversationId, inert.conversationId);
1274
+ assert.equal(restored.accountIdentity, previous.accountIdentity);
1275
+ const restoredPrompt = (await f.trace()).filter(row => row.frame?.type === "user").at(-1).frame.message.content;
1276
+ assert.match(restoredPrompt, /Hello/);
1277
+ assert.match(restoredPrompt, /Continue after the change/);
1278
+ assert.match(restoredPrompt, /Continue after restart/);
1279
+ });
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
+ import { setTimeout as delay } from "node:timers/promises";
3
4
  import { createClaudeConversationTurn, claudeNativeMessageId, runClaudeRenewalTurn,
4
5
  waitForClaudeConversationTurn } from "../src/server/conversation/claudeTurn.js";
5
6
 
@@ -80,6 +81,13 @@ test("lost acknowledgement stays uncertain and stop rejects pending admission",
80
81
  assert.equal(turn.read().text, "");
81
82
  });
82
83
 
84
+ test("storage failure rejects admission and a slow acknowledged write is not a native timeout", async () => {
85
+ const { turn, send } = fixture();
86
+ await send("slow", { timeoutMs: 5, accept: async () => delay(15) });
87
+ await assert.rejects(send("failed", { accept: async () => { throw new Error("Storage offline"); } }), /Storage offline/);
88
+ turn.stop();
89
+ });
90
+
83
91
  test("native session identity and helper output bounds are enforced without truncating answers", async () => {
84
92
  const { turn, send } = fixture();
85
93
  await assert.rejects(turn.receive({ type: "user", session_id: "another-session" }), /different conversation id/);
@@ -92,6 +100,16 @@ test("native session identity and helper output bounds are enforced without trun
92
100
  turn.stop();
93
101
  });
94
102
 
103
+ test("a native error result is failure information rather than an assistant answer", async () => {
104
+ const { turn, send, events } = fixture();
105
+ await send();
106
+ await turn.receive({ type: "result", subtype: "error_during_execution", is_error: true, result: "The model connection failed." });
107
+ assert.equal(turn.read().status, "failed");
108
+ assert.equal(turn.read().error, "The model connection failed.");
109
+ assert.equal(turn.read().text, "");
110
+ assert.equal(events.some(event => event.type === "message"), false);
111
+ });
112
+
95
113
  function renewalFixture() {
96
114
  const input = { clientMessageId: "renewal-operation", prompt: "Approved prompt", timeoutMs: 100 };
97
115
  const uuid = claudeNativeMessageId(input.clientMessageId);