@jskit-ai/assistant-core 0.1.239 → 0.1.241

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 (103) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/package.json +5 -5
  3. package/src/client/conversation/AssistantTranscript.vue +26 -29
  4. package/src/client/conversation/LongTextPreviewBlocks.vue +11 -0
  5. package/src/client/conversation/messageDelivery.js +8 -2
  6. package/src/client/conversation/useAssistantAttachments.js +1 -1
  7. package/src/server/conversation/agentRun.js +12 -198
  8. package/src/server/conversation/attachments.js +6 -5
  9. package/src/server/conversation/boundedToolLoop.js +160 -0
  10. package/src/server/conversation/claudeHistory.js +20 -16
  11. package/src/server/conversation/claudeProcess.js +2 -2
  12. package/src/server/conversation/claudeTurn.js +51 -40
  13. package/src/server/conversation/codexAuthPreflight.js +104 -0
  14. package/src/server/conversation/codexClient.js +11 -16
  15. package/src/server/conversation/codexConfiguration.js +112 -98
  16. package/src/server/conversation/codexConversationStore.js +189 -0
  17. package/src/server/conversation/codexConversations.js +47 -44
  18. package/src/server/conversation/codexDetachedTurn.js +12 -11
  19. package/src/server/conversation/codexEvents.js +95 -99
  20. package/src/server/conversation/codexGoalCommands.js +1 -1
  21. package/src/server/conversation/codexHelperExecution.js +3 -13
  22. package/src/server/conversation/codexHelperThreadLedger.js +7 -13
  23. package/src/server/conversation/codexHelperThreadRecovery.js +2 -16
  24. package/src/server/conversation/codexHelperThreads.js +5 -6
  25. package/src/server/conversation/codexHistoryAdapter.js +32 -26
  26. package/src/server/conversation/codexMessageCommands.js +22 -22
  27. package/src/server/conversation/codexMessageDelivery.js +12 -7
  28. package/src/server/conversation/codexModelCatalog.js +97 -0
  29. package/src/server/conversation/codexNotificationObserver.js +24 -40
  30. package/src/server/conversation/codexNotificationQueue.js +2 -3
  31. package/src/server/conversation/codexOutput.js +20 -27
  32. package/src/server/conversation/codexProcess.js +123 -127
  33. package/src/server/conversation/codexProcessLeader.js +3 -3
  34. package/src/server/conversation/codexProvider.js +31 -614
  35. package/src/server/conversation/codexProviderOwner.js +2 -2
  36. package/src/server/conversation/codexProviderSelection.js +38 -40
  37. package/src/server/conversation/codexRenewalCommands.js +13 -10
  38. package/src/server/conversation/codexRenewalThreads.js +412 -0
  39. package/src/server/conversation/codexRunJournal.js +24 -42
  40. package/src/server/conversation/codexRuntimeLifecycle.js +19 -15
  41. package/src/server/conversation/codexSessionCleanup.js +11 -10
  42. package/src/server/conversation/codexSessionReconciliation.js +6 -6
  43. package/src/server/conversation/codexThreadReadiness.js +19 -33
  44. package/src/server/conversation/codexThreadStatus.js +3 -3
  45. package/src/server/conversation/codexTurn.js +124 -297
  46. package/src/server/conversation/codexTurnRecovery.js +49 -28
  47. package/src/server/conversation/codexTurnState.js +34 -13
  48. package/src/server/conversation/commandWrapper.js +3 -0
  49. package/src/server/conversation/deliveryTiming.js +14 -5
  50. package/src/server/conversation/index.js +1 -0
  51. package/src/server/conversation/localExecution.js +13 -17
  52. package/src/server/conversation/normalize.js +8 -3
  53. package/src/server/conversation/openCodeClient.js +9 -10
  54. package/src/server/conversation/openCodeProcess.js +9 -5
  55. package/src/server/conversation/openCodeRuntime.js +10 -11
  56. package/src/server/conversation/openCodeTurn.js +43 -29
  57. package/src/server/conversation/providers/claudeDriver.js +10 -16
  58. package/src/server/conversation/providers/codexDriver.js +27 -43
  59. package/src/server/conversation/providers/opencodeDriver.js +17 -42
  60. package/src/server/conversation/providers/scoped.js +48 -4
  61. package/src/server/conversation/runtime.js +50 -49
  62. package/src/server/conversation/runtimeStateUpgrade.js +34 -22
  63. package/src/server/conversation/transcript.js +10 -10
  64. package/src/server/hosts/codexConfiguration.js +1 -0
  65. package/src/server/hosts/codexProvider.js +8 -6
  66. package/src/server/index.js +2 -1
  67. package/src/server/lib/assistantToolLoop.js +1 -153
  68. package/src/server/lib/serviceToolCatalog.js +17 -42
  69. package/src/shared/assistantResource.js +4 -2
  70. package/src/shared/conversation/attachments.js +4 -0
  71. package/src/shared/conversation/longTextBlocks.js +54 -19
  72. package/src/shared/conversation/numberedQuestionSugar.js +17 -10
  73. package/src/shared/conversation/pagination.js +4 -6
  74. package/src/shared/conversation/patches.js +1 -4
  75. package/src/shared/conversation/streaming.js +3 -11
  76. package/src/shared/conversation/turns.js +11 -0
  77. package/src/shared/index.js +1 -0
  78. package/test/assistantBoundedToolLoop.test.js +2 -2
  79. package/test/assistantResource.test.js +4 -4
  80. package/test/assistantScroll.browser.test.js +29 -6
  81. package/test/assistantSettingsResource.test.js +2 -2
  82. package/test/claudeConversationSession.test.js +20 -3
  83. package/test/claudeTurn.test.js +27 -7
  84. package/test/codexClient.test.js +20 -0
  85. package/test/codexConversationSession.test.js +5 -3
  86. package/test/codexConversationsCheckpoint.test.js +51 -0
  87. package/test/codexModelCatalog.test.js +1 -1
  88. package/test/codexProviderHome.test.js +51 -0
  89. package/test/codexProviderSelection.test.js +71 -0
  90. package/test/codexTurn.test.js +222 -6
  91. package/test/conversationAgentRun.test.js +2 -1
  92. package/test/conversationAttachments.test.js +27 -2
  93. package/test/conversationDeliveryTiming.test.js +21 -0
  94. package/test/conversationNormalize.test.js +11 -0
  95. package/test/fixtures/nestedListQuestionMessage.md +37 -0
  96. package/test/frameworkActions.test.js +2 -2
  97. package/test/longTextBlocks.test.js +102 -0
  98. package/test/messageDelivery.test.js +7 -1
  99. package/test/openCodeClient.test.js +20 -0
  100. package/test/openCodeRuntime.test.js +1 -1
  101. package/test/openCodeTurn.test.js +90 -0
  102. package/test/opencodeConversationSession.test.js +1 -1
  103. package/test/serviceToolCatalog.test.js +27 -0
@@ -7,8 +7,10 @@ import test from "node:test";
7
7
  import { createMemoryConversationStorage } from "../src/server/conversation/memoryStorage.js";
8
8
  import { createConversationTranscript } from "../src/server/conversation/transcript.js";
9
9
  import { createConversationStreams } from "../src/server/conversation/streams.js";
10
- import { sendCodexAppServerPrompt, CodexAppServerAgentProvider } from "../src/server/conversation/codexProvider.js";
10
+ import { sendCodexAppServerPrompt, CodexAppServerAgentProvider, codexAppServerDeliveredTurn } from "../src/server/conversation/codexProvider.js";
11
11
  import { createCodexHelperThreadLedgerOwner } from "../src/server/conversation/codexHelperThreadLedger.js";
12
+ import { createCodexSessionReconciliation } from "../src/server/conversation/codexSessionReconciliation.js";
13
+ import { createCodexSessionCleanup } from "../src/server/conversation/codexSessionCleanup.js";
12
14
  import {
13
15
  waitForCodexAppServerTurn,
14
16
  createCodexAppServerRunOwner,
@@ -432,6 +434,120 @@ test("native phase changes require the current exact turn and retain its active
432
434
  assert.equal(f.writes.length, 1);
433
435
  });
434
436
 
437
+ test("a settling write for a predecessor turn cannot end the active successor run", async () => {
438
+ for (const state of ["active", "finalizing", "starting"]) {
439
+ const f = runFixture({ state, providerTurnId: "successor" });
440
+ const owner = createCodexAppServerRunOwner();
441
+ const successor = f.read();
442
+ for (const runState of [CODEX_APP_SERVER_RUN_STATE.COMPLETED, CODEX_APP_SERVER_RUN_STATE.FAILED,
443
+ CODEX_APP_SERVER_RUN_STATE.INTERRUPTED]) {
444
+ const result = await owner.writeAgentRun(f.runtime, "session-1", { runState, status: runState,
445
+ threadId: "thread-1", turnId: "turn-1", error: "Predecessor outcome." });
446
+ assert.equal(result.wrote, false);
447
+ assert.equal(result.stale.currentTurnId, "successor");
448
+ assert.equal(result.stale.patchTurnId, "turn-1");
449
+ }
450
+ assert.deepEqual(f.read(), successor);
451
+ assert.deepEqual(f.writes, []);
452
+ const settled = await owner.writeAgentRun(f.runtime, "session-1", { runState: CODEX_APP_SERVER_RUN_STATE.COMPLETED,
453
+ status: "completed", threadId: "thread-1", turnId: "successor" });
454
+ assert.equal(settled.wrote, true, "The successor's own outcome still settles it");
455
+ }
456
+ });
457
+
458
+ test("startup unsubscribe skips session records without an identity instead of inventing one", async () => {
459
+ const prepared = [];
460
+ const reconciliation = createCodexSessionReconciliation({
461
+ providerSessions: { owner: { async unsubscribeSessionThread(_key, sessionId) {
462
+ return { ok: true, sessionId, status: "unsubscribed" };
463
+ } } },
464
+ namespace: sessionId => `project:${sessionId}`,
465
+ debugLog() {}, debugError: error => error
466
+ });
467
+ const result = await reconciliation.unsubscribeSessions([{ title: "no identity" }, { id: "session-1" },
468
+ "session-1", { sessionId: "session-2" }], {
469
+ enabled: true,
470
+ async session(sessionId) { prepared.push(sessionId); return { threadId: `thread-${sessionId}` }; }
471
+ });
472
+ assert.deepEqual(prepared, ["session-1", "session-2"]);
473
+ assert.equal(JSON.stringify(result).includes("[object Object]"), false);
474
+ });
475
+
476
+ test("session cleanup owns its single-flight closures and restricted record without consumer state", async () => {
477
+ const sessionClosures = new Map();
478
+ const releases = [];
479
+ const held = [];
480
+ const cleanupOwner = createCodexSessionCleanup({
481
+ namespace: id => `project:${id}`,
482
+ runOwner: {},
483
+ sessionClosures,
484
+ providerSessions: { owner: {
485
+ async drainThreadEnvironmentTasks() {},
486
+ async releaseSession(sessionKey) {
487
+ const gate = Promise.withResolvers();
488
+ held.push(gate);
489
+ await gate.promise;
490
+ releases.push(sessionKey);
491
+ return { exitFailure: null };
492
+ }
493
+ } },
494
+ helperLifecycle: { assertRestored() {}, async restoreThreads() {}, assertRetired() {}, async retireAll() {} },
495
+ journal: { clearSessionRecoveryTimers() {} },
496
+ conversations: { async closeCodexAppServerConversations() {} },
497
+ debugLog() {}, debugError: error => error
498
+ });
499
+ const reads = [];
500
+ const publicChecks = [];
501
+ const cleanup = (label, restricted = false) => ({
502
+ sessionKey: "project:session-1",
503
+ restricted,
504
+ async assertPublicAccess() { publicChecks.push(label); },
505
+ async read() {
506
+ reads.push(label);
507
+ return { helpers: {}, projectRuntimeRoot: "", session: {}, async complete() {} };
508
+ }
509
+ });
510
+ const flush = () => new Promise(resolve => setImmediate(resolve));
511
+
512
+ const first = cleanupOwner.closeSession("session-1", cleanup("public"));
513
+ const joined = cleanupOwner.closeSession("session-1", cleanup("joined"));
514
+ assert.equal(sessionClosures.size, 1, "One closure per session key");
515
+ const restricted = cleanupOwner.closeSession("session-1", cleanup("restricted", true));
516
+ await flush();
517
+ held.shift().resolve();
518
+ await Promise.all([first, joined]);
519
+ await flush();
520
+ assert.deepEqual(reads, ["public", "restricted"], "A restricted close waits for the public one, then runs its own");
521
+ const publicAfterRestricted = cleanupOwner.closeSession("session-1", cleanup("late-public"));
522
+ await flush();
523
+ assert.deepEqual(publicChecks, ["late-public"], "Joining a restricted close proves public access first");
524
+ held.shift().resolve();
525
+ await Promise.all([restricted, publicAfterRestricted]);
526
+ assert.deepEqual(reads, ["public", "restricted"]);
527
+ assert.deepEqual(releases, ["project:session-1", "project:session-1"]);
528
+ assert.equal(sessionClosures.size, 0);
529
+ });
530
+
531
+ test("the run owner's session closures are the ones its runtime lifecycle drains", async () => {
532
+ const draining = Promise.withResolvers();
533
+ const owner = createCodexAppServerRunOwner({ providerSessions: { owner: { drainThreadEnvironmentTasks: () => draining.promise } } });
534
+ const closing = owner.closeSession("session-1", { sessionKey: "project:session-1",
535
+ async read() { throw new Error("Controlled preparation stop"); } });
536
+ assert.ok(owner.runtimeLifecycle.sessionClosures.get("project:session-1") instanceof Promise);
537
+ draining.resolve();
538
+ await assert.rejects(closing, /Controlled preparation stop/);
539
+ assert.equal(owner.runtimeLifecycle.sessionClosures.size, 0);
540
+ });
541
+
542
+ test("the run owner exposes only operations its consumers use, not facet recovery state", () => {
543
+ const owner = createCodexAppServerRunOwner();
544
+ for (const internal of ["completedTurns", "automaticHookThreads", "conversationTurnStarts", "reasoningPersistQueues",
545
+ "threadReconciliations", "reasoningTurns", "finalAssistantResults", "mirroredTerminalItems", "clearActiveTimer",
546
+ "clearFinalizingTimer", "adoptSuccessorTurn", "markTurnActive", "markTurnFinalizing", "checkManagedConnection"]) {
547
+ assert.equal(Object.hasOwn(owner, internal), false, internal);
548
+ }
549
+ });
550
+
435
551
  test("original pending ownership and terminal outer identity are preserved without weakening run-state errors", () => {
436
552
  const run = { inputSource: "chat", pendingUserMessageClientIds: [0, false, " first ", "second"] };
437
553
  assert.deepEqual(codexAppServerPendingUserMessageOwnership(run, "first"), { clientId: "first", inputSource: "chat" });
@@ -978,9 +1094,9 @@ test("original settlement clears the same recovery timers and skips stale idle c
978
1094
  t.after(() => timers.forEach(clearTimeout));
979
1095
  owner.activeTimers.set(`project:${sessionId}`, timer());
980
1096
  owner.activeTimers.set("project:other", timer());
981
- owner.finalizingTimers.set(owner.resultFinalizationKey(sessionId, threadId, turnId), timer());
982
- owner.finalizingTimers.set(owner.resultFinalizationKey(sessionId, threadId, "older"), timer());
983
- owner.finalizingTimers.set(owner.resultFinalizationKey("other", threadId, turnId), timer());
1097
+ owner.finalizingTimers.set(owner.resultFinalizationKey(sessionId, threadId, turnId), { timer: timer() });
1098
+ owner.finalizingTimers.set(owner.resultFinalizationKey(sessionId, threadId, "older"), { timer: timer() });
1099
+ owner.finalizingTimers.set(owner.resultFinalizationKey("other", threadId, turnId), { timer: timer() });
984
1100
  f.run = { ...f.run, state: "interrupted", providerStatus: "interrupted" };
985
1101
  const result = await owner.markTurnIdle(sessionId, { threadId, turnId, status: "completed" });
986
1102
  assert.equal(result.processed, false);
@@ -2373,6 +2489,72 @@ for (const [status, cause, explicit, expected] of [
2373
2489
  });
2374
2490
  }
2375
2491
 
2492
+ test("a native stop outcome rereads the run after its history read and leaves an adopted successor running", async () => {
2493
+ const notices = [];
2494
+ const checkpoints = [];
2495
+ const f = await receiptFixture({
2496
+ outcomeNotice(_runtime, sessionId, threadId, turnId, outcome) { notices.push({ sessionId, threadId, turnId, outcome }); },
2497
+ checkpoint(sessionId, value) { checkpoints.push({ sessionId, ...value }); }
2498
+ });
2499
+ f.history = [turn(f.turnId, "interrupted")];
2500
+ const reading = Promise.withResolvers(); const release = Promise.withResolvers();
2501
+ const list = f.provider.listThreadTurns;
2502
+ f.provider.listThreadTurns = async (...args) => {
2503
+ const snapshot = await list(...args);
2504
+ reading.resolve(); await release.promise;
2505
+ return snapshot;
2506
+ };
2507
+ try {
2508
+ const pending = f.owner.stopTurnWithProviderFailure(f.sessionId, f.threadId, f.turnId, {
2509
+ status: "interrupted", provider: f.provider, verifyInactive: false
2510
+ });
2511
+ await reading.promise;
2512
+ f.run = { ...f.run, providerTurnId: "successor", state: "active", providerStatus: "inProgress" };
2513
+ const successorRun = structuredClone(f.run);
2514
+ release.resolve();
2515
+ assert.equal((await pending).reason, "stale_turn_state");
2516
+ assert.deepEqual(f.run, successorRun);
2517
+ assert.deepEqual(notices, []);
2518
+ assert.deepEqual(checkpoints, []);
2519
+ } finally {
2520
+ f.owner.clearSessionRecoveryTimers(f.sessionId);
2521
+ }
2522
+ });
2523
+
2524
+ test("a delivered native turn reads its identity, status and provider error from the one sendTurn shape", async () => {
2525
+ const provider = new CodexAppServerAgentProvider({}, { credentials: { assertCurrent() {} } });
2526
+ const responses = [
2527
+ { turn: { id: "turn-failed", status: "failed", error: { message: "You've hit your usage limit." } } },
2528
+ { turnId: "turn-flat", status: "failed", error: "Flat provider failure." },
2529
+ { turn: { id: "turn-running", status: "inProgress" } }
2530
+ ];
2531
+ provider.client = { isOpen: () => true, close() {}, async request(method) {
2532
+ assert.equal(method, "turn/start");
2533
+ return responses.shift();
2534
+ } };
2535
+ try {
2536
+ const delivered = [];
2537
+ for (let index = 0; index < 3; index += 1) {
2538
+ delivered.push(codexAppServerDeliveredTurn(await sendCodexAppServerPrompt({ provider, prompt: "Go", threadId: "thread-1" })));
2539
+ }
2540
+ assert.deepEqual(delivered, [
2541
+ { turnId: "turn-failed", status: "failed", error: "You've hit your usage limit." },
2542
+ { turnId: "turn-flat", status: "failed", error: "Flat provider failure." },
2543
+ { turnId: "turn-running", status: "inProgress", error: "" }
2544
+ ]);
2545
+ } finally {
2546
+ provider.close();
2547
+ }
2548
+ });
2549
+
2550
+ test("a host provider that returns its raw turn unnormalized still delivers its status and error", async () => {
2551
+ const provider = { async sendTurn() {
2552
+ return { id: "t", raw: { status: "failed", error: { message: "boom" } } };
2553
+ } };
2554
+ const delivered = codexAppServerDeliveredTurn(await sendCodexAppServerPrompt({ provider, prompt: "Go", threadId: "thread-1" }));
2555
+ assert.deepEqual(delivered, { turnId: "t", status: "failed", error: "boom" });
2556
+ });
2557
+
2376
2558
  test("user Stop records its exact cause before queued native completion while the interrupt reply is held", async () => {
2377
2559
  const notices = [];
2378
2560
  const f = await receiptFixture({ outcomeNotice(_runtime, sessionId, threadId, turnId, outcome) {
@@ -2595,7 +2777,7 @@ for (const change of ["concurrent", "cleanup", "successor"]) {
2595
2777
  t.mock.timers.tick(20);
2596
2778
  await reading.promise;
2597
2779
  assert.equal(owner.finalizingTimers.size, 1, "Firing does not discard the first post-read clock");
2598
- const expiry = [...owner.finalizingTimers.values()][0].finalizingRecovery;
2780
+ const expiry = [...owner.finalizingTimers.values()][0].recovery;
2599
2781
  assert.ok(expiry instanceof Promise);
2600
2782
  let pending;
2601
2783
  if (change === "concurrent") pending = owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false });
@@ -2612,6 +2794,40 @@ for (const change of ["concurrent", "cleanup", "successor"]) {
2612
2794
  });
2613
2795
  }
2614
2796
 
2797
+ test("native delivery failure rereads the run after its last history read and cannot settle an adopted successor", async t => {
2798
+ t.mock.timers.enable({ apis: ["Date"], now: Date.parse("2026-10-07T00:00:00Z") });
2799
+ const f = await settlementFixture();
2800
+ const { sessionId, threadId, turnId } = f;
2801
+ const owner = createCodexAppServerRunOwner({ finalizingGraceMs: 500,
2802
+ createRuntime: async () => ({ store: f.store, getSession: async () => ({ sessionId, agentRuns: [structuredClone(f.run)] }) }),
2803
+ createStore: async () => f.store, acquireProvider: async () => f.provider,
2804
+ checkpoint: async (id, input) => { f.checkpoints.push({ sessionId: id, ...input }); }
2805
+ });
2806
+ t.after(() => owner.clearSessionRecoveryTimers(sessionId));
2807
+ f.run = { ...f.run, state: "finalizing", providerStatus: "completed", finishedAt: new Date().toISOString() };
2808
+ f.history = [turn(turnId, "completed")];
2809
+ t.mock.timers.tick(501);
2810
+ const reading = Promise.withResolvers(); const release = Promise.withResolvers();
2811
+ const list = f.provider.listThreadTurns;
2812
+ let reads = 0;
2813
+ f.provider.listThreadTurns = async (...args) => {
2814
+ const snapshot = await list(...args);
2815
+ reads += 1;
2816
+ if (reads === 2) { reading.resolve(); await release.promise; }
2817
+ return snapshot;
2818
+ };
2819
+ const pending = owner.completeTurn(sessionId, threadId, turnId, { verifyInactive: false });
2820
+ await reading.promise;
2821
+ f.run = { ...f.run, providerTurnId: "successor", state: "active", providerStatus: "inProgress" };
2822
+ const successorRun = structuredClone(f.run);
2823
+ release.resolve();
2824
+ const result = await pending;
2825
+ assert.equal(result.reason, "stale_turn_state");
2826
+ assert.deepEqual(f.run, successorRun, "The predecessor's delivery failure cannot settle the successor");
2827
+ assert.deepEqual(f.checkpoints, []);
2828
+ assert.deepEqual(await f.replies(), []);
2829
+ });
2830
+
2615
2831
 
2616
2832
  test("native after-history grace retries a rejected expiry read without granting another interval", async t => {
2617
2833
  t.mock.timers.enable({ apis: ["Date"], now: Date.parse("2026-10-07T00:00:00Z") });
@@ -2643,7 +2859,7 @@ test("native after-history grace retries a rejected expiry read without granting
2643
2859
  t.mock.timers.tick(20);
2644
2860
  await failedRead.promise;
2645
2861
  const timer = [...owner.finalizingTimers.values()][0];
2646
- const expiry = timer.finalizingRecovery;
2862
+ const expiry = timer.recovery;
2647
2863
  const rejected = assert.rejects(expiry, /Controlled transient session read failure/);
2648
2864
  release.resolve();
2649
2865
  await rejected;
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { conversationAgentRunEvent, conversationAgentRunRecord, createCodexConversationRunStore, createCodexConversationStore } from "../src/server/conversation/agentRun.js";
3
+ import { conversationAgentRunEvent, conversationAgentRunRecord } from "../src/server/conversation/agentRun.js";
4
+ import { createCodexConversationRunStore, createCodexConversationStore } from "../src/server/conversation/codexConversationStore.js";
4
5
  import { createMemoryConversationStorage } from "../src/server/conversation/memoryStorage.js";
5
6
  import { createReentrantConversationStorage } from "../src/server/conversation/storage.js";
6
7
  import { createConversationTranscript } from "../src/server/conversation/transcript.js";
@@ -3,8 +3,11 @@ import assert from "node:assert/strict";
3
3
  import test from "node:test";
4
4
  import { createConversationRuntime, createMemoryConversationStorage } from "../src/server/conversation/index.js";
5
5
  import { codexLocalImageInput, codexTurnInput } from "../src/server/conversation/codexProvider.js";
6
- import { createConversationAttachmentReader } from "../src/server/conversation/attachments.js";
7
- import { conversationAttachmentManifest, prepareConversationAttachmentMessage } from "../src/shared/conversation/attachments.js";
6
+ import { conversationAttachmentIds, createConversationAttachmentReader } from "../src/server/conversation/attachments.js";
7
+ import { attachmentIdsField } from "../src/shared/index.js";
8
+ import { ASSISTANT_ATTACHMENT_MAX_ITEMS as CLIENT_ATTACHMENT_MAX_ITEMS } from "../src/client/conversation/useAssistantAttachments.js";
9
+ import { ASSISTANT_ATTACHMENT_ID_MAX_LENGTH, ASSISTANT_ATTACHMENT_MAX_ITEMS, conversationAttachmentManifest,
10
+ prepareConversationAttachmentMessage } from "../src/shared/conversation/attachments.js";
8
11
 
9
12
  const image = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aN7sAAAAASUVORK5CYII=", "base64");
10
13
  const receipt = { attachmentId: "picture", fileName: "picture.png", size: image.length };
@@ -253,3 +256,25 @@ test("attachment validation displays the application's configured binary or deci
253
256
  }
254
257
  }
255
258
  });
259
+
260
+ test("one attachment-ID limit governs the schema field, the server check, continuity batches and the composer", async () => {
261
+ assert.equal(attachmentIdsField.maxItems, ASSISTANT_ATTACHMENT_MAX_ITEMS);
262
+ assert.equal(attachmentIdsField.items.maxLength, ASSISTANT_ATTACHMENT_ID_MAX_LENGTH);
263
+ assert.equal(CLIENT_ATTACHMENT_MAX_ITEMS, ASSISTANT_ATTACHMENT_MAX_ITEMS);
264
+ const ids = Array.from({ length: ASSISTANT_ATTACHMENT_MAX_ITEMS }, (_, index) => `file-${index}`);
265
+ assert.deepEqual(conversationAttachmentIds(ids), ids);
266
+ assert.throws(() => conversationAttachmentIds([...ids, "one-too-many"]), { code: "conversation_invalid_attachments" });
267
+ assert.deepEqual(conversationAttachmentIds(["x".repeat(ASSISTANT_ATTACHMENT_ID_MAX_LENGTH)]).length, 1);
268
+ assert.throws(() => conversationAttachmentIds(["x".repeat(ASSISTANT_ATTACHMENT_ID_MAX_LENGTH + 1)]), { code: "conversation_invalid_attachments" });
269
+ const batches = [];
270
+ const reader = createConversationAttachmentReader({ context, conversationId: "one", signal: new AbortController().signal,
271
+ authorize: async () => {}, types: ["text"], maximumBytes: 1024 * 1024,
272
+ attachments: { resolve: ({ attachmentIds }) => {
273
+ batches.push(attachmentIds.length);
274
+ return { attachments: attachmentIds.map(attachmentId => ({ attachmentId, fileName: `${attachmentId}.txt`, size: 1 })),
275
+ content: [{ type: "text", text: "x" }] };
276
+ } } });
277
+ const continued = await reader([...ids, "one-more"].map(attachmentId => ({ attachmentId })));
278
+ assert.deepEqual(batches, [ASSISTANT_ATTACHMENT_MAX_ITEMS, 1]);
279
+ assert.equal(continued.attachments.length, ASSISTANT_ATTACHMENT_MAX_ITEMS + 1);
280
+ });
@@ -1,6 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { createConversationDeliveryTiming } from "../src/server/conversation/deliveryTiming.js";
4
+ import { createConversationRuntime, createMemoryConversationStorage } from "../src/server/conversation/index.js";
4
5
 
5
6
  test("native engines share bounded, content-free timing and independent message identities", () => {
6
7
  for (const engine of ["codex", "claude", "opencode"]) {
@@ -48,3 +49,23 @@ test("diagnostic failures and unfinished asynchronous observers never block deli
48
49
  await Promise.resolve();
49
50
  pending.resolve();
50
51
  });
52
+
53
+ test("host preparation stages are reported only when the host declares them", () => {
54
+ const events = [];
55
+ const report = event => events.push(event.stage);
56
+ const plain = createConversationDeliveryTiming({ engine: "claude", conversationId: "chat", messageId: "one", report });
57
+ plain("received"); plain("host-step");
58
+ assert.deepEqual(events, ["received"]);
59
+ const declared = createConversationDeliveryTiming({ engine: "claude", conversationId: "chat", messageId: "two", report,
60
+ hostStages: ["host-step"] });
61
+ declared("host-step"); declared("other-step");
62
+ assert.deepEqual(events, ["received", "host-step"]);
63
+ });
64
+
65
+ test("conversation runtimes reject malformed host delivery stage names", () => {
66
+ for (const hostDeliveryStages of ["host-step", ["Host"], ["host step"], [7], ["-step"]]) {
67
+ assert.throws(() => createConversationRuntime({ authorize: () => true, hostDeliveryStages }),
68
+ { name: "TypeError", message: "Host delivery stages must be lowercase hyphenated names." });
69
+ }
70
+ createConversationRuntime({ authorize: () => true, storage: createMemoryConversationStorage(), hostDeliveryStages: ["git-step"] });
71
+ });
@@ -0,0 +1,11 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { normalizeNullableText } from "../src/server/conversation/normalize.js";
4
+
5
+ test("conversation text normalization blanks only null and undefined", () => {
6
+ assert.equal(normalizeNullableText(null), "");
7
+ assert.equal(normalizeNullableText(undefined), "");
8
+ assert.equal(normalizeNullableText(0), "0");
9
+ assert.equal(normalizeNullableText(false), "false");
10
+ assert.equal(normalizeNullableText(" id "), "id");
11
+ });
@@ -0,0 +1,37 @@
1
+ Here's how I'd run a final sweep in three steps: I do a systematic pass on my own first, then a short session with you for the parts that need you, then I tidy up.
2
+
3
+ **Step 1: I sweep on my own (no need for you)**
4
+ - **Click everything.** I go through every visible control and compare it against the specs: every button, the + menu, every right-click menu (bots, messages, sections, Library items), every keyboard shortcut on the shortcuts sheet, and every settings row. Anything with no spec is a gap.
5
+ - **Gather the "unverified" lists.** Every spec has one, and I'll merge them all into a single gap list. Each item gets labelled as one of three kinds:
6
+ - **I can observe it now:** for example, editing and deleting pages, or a bot made from a template.
7
+ - **Needs you:** real accounts, audio, a second account, payment, your phone, links that open in a browser.
8
+ - **Can't be observed at all.**
9
+ - **Output:** one gap list, saved to your Session Dropzone, with every item marked done, observable, needs you, or impossible.
10
+
11
+ **Step 2: a session with you, for the "needs you" items and things only you know about**
12
+ - **You drive the reference over VNC**, using it as you normally would: your knowledge of features, the test Google account, the phone app, links opening in your browser.
13
+ - **I record the screen** at a low frame rate while you work, and capture stills when you tell me something interesting is happening. Just tell me what you did in plain words ("now I'm connecting Gmail", "this is the share link in my browser").
14
+ - **Things outside the reference machine:** put screenshots in the Session Dropzone, for example from the mobile app, the help pages, or emails it sends. I'll file them.
15
+ - **What I need from you:**
16
+ - which features you know or suspect exist that I haven't covered (I'll ask about specific suspects from my list)
17
+ - the test Google account, so I can see the real connected states
18
+ - your call on anything that sends real data or costs money
19
+
20
+ **Step 3: I tidy up**
21
+ - **For mercbot:**
22
+ - Newly discovered areas go to mercbot as additions to the feature map.
23
+ - Each one gets a spec, sent when you or mercbot ask for it. The phase-2 rules allow that.
24
+ - **For you:** a final coverage summary.
25
+
26
+ **One thing to keep in mind:** you'll see more of the original than mercbot ever should. During this phase, keep sending findings through me rather than straight to mercbot.
27
+
28
+ [1] Shall I start Step 1 (the solo sweep and gap list) now?
29
+ Possible answers:
30
+ - Yes, start now
31
+ - Wait until I'm ready to do Step 2 as well
32
+
33
+ [2] For Step 2, how would you like to work?
34
+ Possible answers:
35
+ - I drive over VNC while you record
36
+ - I'll put screenshots in the Dropzone
37
+ - A mix of both
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { readdir } from "node:fs/promises";
4
4
  import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
5
- import { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
5
+ import { resolveSchemaTransportSchemaDefinition } from "@jskit-ai/kernel/shared/validators";
6
6
  import { returnJsonApiData } from "@jskit-ai/http-runtime/shared";
7
7
  import { createServiceToolCatalog } from "../src/server/lib/serviceToolCatalog.js";
8
8
  import { createAssistantActions } from "../../assistant-runtime/src/server/actions.js";
@@ -95,7 +95,7 @@ test("every account, workspace, console and assistant automation action has a co
95
95
  direct++;
96
96
  assert.ok(assistant.description, spec.id);
97
97
  assert.equal(typeof assistant.transformResult, "function", spec.id);
98
- assert.ok(resolveStructuredSchemaTransportSchema(assistant.output), spec.id);
98
+ assert.ok(resolveSchemaTransportSchemaDefinition(assistant.output), spec.id);
99
99
  assert.ok(fixtures[spec.id], `Add execution coverage for ${spec.id}`);
100
100
  }
101
101
  assert.equal(direct, 22);
@@ -0,0 +1,102 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import test from "node:test";
4
+ import { parseLongTextReviewBlocks } from "../src/shared/conversation/longTextBlocks.js";
5
+ import { parseNumberedQuestionPrompt } from "../src/shared/conversation/numberedQuestionSugar.js";
6
+
7
+ const message = readFileSync(new URL("./fixtures/nestedListQuestionMessage.md", import.meta.url), "utf8");
8
+
9
+ function outline(blocks = []) {
10
+ return blocks.map((block) => {
11
+ if (block.type !== "ul" && block.type !== "ol") {
12
+ return `${block.type}: ${block.text.slice(0, 40)}`;
13
+ }
14
+ return {
15
+ [block.type]: block.items.map((item) => (
16
+ item.blocks ? { [item.text.slice(0, 40)]: outline(item.blocks) } : item.text.slice(0, 40)
17
+ ))
18
+ };
19
+ });
20
+ }
21
+
22
+ test("a question message keeps its intro paragraphs and nested lists apart from the question cards", () => {
23
+ const prompt = parseNumberedQuestionPrompt(message);
24
+ assert.deepEqual(prompt.questions.map((question) => [question.label, question.choices.length]), [
25
+ ["Shall I start Step 1 (the solo sweep and gap list) now?", 2],
26
+ ["For Step 2, how would you like to work?", 3]
27
+ ]);
28
+ assert.equal(prompt.outro, "");
29
+
30
+ assert.deepEqual(outline(parseLongTextReviewBlocks(prompt.intro)), [
31
+ "paragraph: Here's how I'd run a final sweep in thre",
32
+ "paragraph: **Step 1: I sweep on my own (no need for",
33
+ {
34
+ ul: [
35
+ "**Click everything.** I go through every",
36
+ {
37
+ "**Gather the \"unverified\" lists.** Every": [
38
+ { ul: ["**I can observe it now:** for example, e", "**Needs you:** real accounts, audio, a s", "**Can't be observed at all.**"] }
39
+ ]
40
+ },
41
+ "**Output:** one gap list, saved to your "
42
+ ]
43
+ },
44
+ "paragraph: **Step 2: a session with you, for the \"n",
45
+ {
46
+ ul: [
47
+ "**You drive the reference over VNC**, us",
48
+ "**I record the screen** at a low frame r",
49
+ "**Things outside the reference machine:*",
50
+ {
51
+ "**What I need from you:**": [
52
+ { ul: ["which features you know or suspect exist", "the test Google account, so I can see th", "your call on anything that sends real da"] }
53
+ ]
54
+ }
55
+ ]
56
+ },
57
+ "paragraph: **Step 3: I tidy up**",
58
+ {
59
+ ul: [
60
+ {
61
+ "**For mercbot:**": [
62
+ { ul: ["Newly discovered areas go to mercbot as ", "Each one gets a spec, sent when you or m"] }
63
+ ]
64
+ },
65
+ "**For you:** a final coverage summary."
66
+ ]
67
+ },
68
+ "paragraph: **One thing to keep in mind:** you'll se"
69
+ ]);
70
+ });
71
+
72
+ test("nested lists follow item content indentation and return to the parent level", () => {
73
+ assert.deepEqual(parseLongTextReviewBlocks([
74
+ "1. First",
75
+ " - child a",
76
+ "\t- child b",
77
+ " 1) grandchild",
78
+ "2. Second",
79
+ " - too shallow for the ordered item, so a sibling list item"
80
+ ].join("\n")), [
81
+ {
82
+ items: [
83
+ {
84
+ blocks: [{
85
+ items: [
86
+ { text: "child a" },
87
+ { blocks: [{ items: [{ text: "grandchild" }], type: "ol" }], text: "child b" }
88
+ ],
89
+ type: "ul"
90
+ }],
91
+ text: "First"
92
+ },
93
+ { text: "Second" }
94
+ ],
95
+ type: "ol"
96
+ },
97
+ {
98
+ items: [{ text: "too shallow for the ordered item, so a sibling list item" }],
99
+ type: "ul"
100
+ }
101
+ ]);
102
+ });
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { nextTick, ref } from "vue";
4
- import { createAssistantMessageDelivery, unmatchedOptimisticMessages } from "../src/client/conversation/messageDelivery.js";
4
+ import { createAssistantMessageDelivery, draftWithRestoredMessage, unmatchedOptimisticMessages } from "../src/client/conversation/messageDelivery.js";
5
5
  import { normalizeConversationTurn } from "../src/shared/conversation/patches.js";
6
6
 
7
7
  test("proven no-admission releases only the exact uncertain message without accepting or losing its payload", async () => {
@@ -429,3 +429,9 @@ test("a deliberate new message can follow known uncertainty without resending th
429
429
  assert.equal(delivery.find("old").status, "uncertain");
430
430
  assert.equal(delivery.find("new").status, "accepted");
431
431
  });
432
+
433
+ test("a restored message leads the draft once", () => {
434
+ assert.equal(draftWithRestoredMessage("First", ""), "First");
435
+ assert.equal(draftWithRestoredMessage("First", "New draft"), "First\n\nNew draft");
436
+ assert.equal(draftWithRestoredMessage("First", "First\n\nNew draft"), "First\n\nNew draft");
437
+ });
@@ -699,3 +699,23 @@ test("stopped native WAL inspection reads newest committed OpenCode rows without
699
699
  }), /Native storage changed during inspection/u);
700
700
  await assert.rejects(lstat(path.dirname(snapshot)), { code: "ENOENT" });
701
701
  });
702
+
703
+ test("OpenCode message reads continue with older native pages through the before cursor", async () => {
704
+ const requests = [];
705
+ const client = createOpenCodeServerClient({ baseUrl: "http://127.0.0.1:4096", password: "private",
706
+ fetchImpl: async (url) => {
707
+ requests.push(new URL(url).search);
708
+ const older = new URL(url).searchParams.get("before");
709
+ return new Response(JSON.stringify([{ info: { id: older ? "msg_old" : "msg_new", role: "user" }, parts: [] }]), {
710
+ headers: older ? {} : { "x-next-cursor": "opaque+/=" }
711
+ });
712
+ }
713
+ }).forDirectory("/work/selected");
714
+ const newest = await client.messages("ses_paged", { limit: 1 });
715
+ assert.equal(newest.nextCursor, "opaque+/=");
716
+ assert.equal(newest.data[0].id, "msg_new");
717
+ const older = await client.messages("ses_paged", { limit: 1, before: newest.nextCursor });
718
+ assert.equal(older.data[0].id, "msg_old");
719
+ assert.equal("nextCursor" in older, false);
720
+ assert.deepEqual(requests, ["?limit=1", `?limit=1&before=${encodeURIComponent("opaque+/=")}`]);
721
+ });
@@ -773,7 +773,7 @@ test("credential publication refuses another provider and unowned consumers befo
773
773
  await assert.rejects(runtime.invalidateRuntimes({ modelProviderId: "deepseek", publish() { published = true; } }, {
774
774
  release: { ownsTarget: target => !unowned || target !== peer,
775
775
  beforeRelease: async target => effects.push(target.key), onRemoved: async () => {}, failure: error => { throw error; } }
776
- }), { code: "assistant_opencode_connection_in_use" });
776
+ }), { code: "assistant_opencode_connection_in_use", message: /^Close the other OpenCode conversations that use this connection, then retry/u });
777
777
  assert.equal(published, false);
778
778
  assert.deepEqual(effects, []);
779
779
  assert.deepEqual(stops, []);