@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
@@ -13,6 +13,19 @@ import {
13
13
  const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
14
14
  const FIXTURE_ROOT = path.resolve(TEST_DIRECTORY, "../fixtures/responsive-assistant");
15
15
  const RUN_BROWSER_TEST = process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION === "1";
16
+ // Turns use content-visibility estimates. Moving the reader materializes rows
17
+ // near the new position, and Chromium's scroll anchoring then shifts scrollTop
18
+ // to keep the same content in view. Hold a reading position only once that
19
+ // layout has settled, so later checks measure the conversation, not the estimates.
20
+ async function settleReaderAt(body, scrollTop) {
21
+ await expect.poll(() => body.evaluate(async (element, top) => {
22
+ element.scrollTop = top;
23
+ const height = element.scrollHeight;
24
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
25
+ return element.scrollTop === top && element.scrollHeight === height;
26
+ }, scrollTop)).toBe(true);
27
+ }
28
+
16
29
  const VIEWPORTS = Object.freeze([
17
30
  Object.freeze({ name: "phone", width: 390, height: 844 }),
18
31
  Object.freeze({ name: "compact", width: 800, height: 900 }),
@@ -405,6 +418,14 @@ test("upward scrolling loads older history once and preserves retry and selectio
405
418
  await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
406
419
  await expect(requests).toHaveText("History requests: 3");
407
420
  await external("Exhaust history");
421
+ // Removing the history control re-anchors the scroller, which would
422
+ // cancel a keyboard scroll already in flight; press Home once it settles.
423
+ await expect(page.getByRole("button", { name: "Load older messages", exact: true })).toHaveCount(0);
424
+ await expect.poll(() => body.evaluate(async element => {
425
+ const height = element.scrollHeight;
426
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
427
+ return element.scrollHeight === height;
428
+ })).toBe(true);
408
429
  await expect.poll(async () => {
409
430
  await body.focus();
410
431
  return body.evaluate(element => document.activeElement === element);
@@ -412,7 +433,6 @@ test("upward scrolling loads older history once and preserves retry and selectio
412
433
  await body.press("Home");
413
434
  await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(0);
414
435
  await expect(requests).toHaveText("History requests: 3");
415
- await expect(page.getByRole("button", { name: "Load older messages", exact: true })).toHaveCount(0);
416
436
  assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
417
437
  assert.deepEqual(errors, []);
418
438
  await page.close();
@@ -1235,15 +1255,18 @@ test("connection warnings keep avatar controls and Reload reachable without repl
1235
1255
  await body.hover();
1236
1256
  await page.mouse.wheel(0, -1000);
1237
1257
  await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeGreaterThan(50);
1238
- await body.evaluate(element => { element.scrollTop = 420; window.errorAvatarBody = element; });
1239
- await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(420);
1258
+ await settleReaderAt(body, 420);
1259
+ await body.evaluate(element => { window.errorAvatarBody = element; });
1240
1260
  const history = await body.locator(".assistant-transcript__turn").allTextContents();
1241
1261
  let reloads = 0;
1242
1262
  for (const size of ["standard", "large", "hidden"]) {
1243
1263
  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);
1264
+ // Establish the reading position after changing the fixture-only avatar
1265
+ // preference. A reader's own wheel detaches the view from the latest
1266
+ // message even if a slow initial bottom scroll resumed following.
1267
+ await body.hover();
1268
+ await page.mouse.wheel(0, -200);
1269
+ await settleReaderAt(body, 420);
1247
1270
  await external("Show update error");
1248
1271
  await expect(conversation).toHaveClass(/assistant-conversation--error/u);
1249
1272
  await expect(avatar).toHaveCSS("position", "absolute");
@@ -1,11 +1,11 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
- import { resolveStructuredSchemaTransportSchema } from "@jskit-ai/kernel/shared/validators";
3
+ import { resolveSchemaTransportSchemaDefinition } from "@jskit-ai/kernel/shared/validators";
4
4
  import { validateOperationSection } from "@jskit-ai/http-runtime/shared/validators/operationValidation";
5
5
  import { assistantConfigResource } from "../src/shared/assistantSettingsResource.js";
6
6
 
7
7
  test("assistant config resource exposes valid output schema", () => {
8
- const viewSchema = resolveStructuredSchemaTransportSchema(
8
+ const viewSchema = resolveSchemaTransportSchemaDefinition(
9
9
  assistantConfigResource.operations.view.output,
10
10
  {
11
11
  context: "assistant config view output",
@@ -320,7 +320,13 @@ async function fixture(t, options = {}) {
320
320
  attachment: { type: "goal_status", condition, sentinel: true, met: condition === "clear" } });
321
321
  if (process.env.TEST_GOAL_LOST_ACK) return;
322
322
  emit(frame);
323
- if (condition === "clear") emit({ type: "result", subtype: "success", result: "" });
323
+ if (condition === "clear") {
324
+ // Claude 2.1.289 saves the clear's local-command receipt and ends it
325
+ // with a result naming the command and the authored message.
326
+ record({ type: "system", subtype: "local_command", uuid: frame.uuid + "-clear", parentUuid: frame.uuid,
327
+ commandRun: { command: "goal", args: "clear" }, content: "<local-command-stdout>Goal cleared: prior goal</local-command-stdout>" });
328
+ emit({ type: "result", subtype: "success", result: "", local_command: "goal", user_message_uuid: frame.uuid });
329
+ }
324
330
  return;
325
331
  }
326
332
  emit(frame);
@@ -1167,8 +1173,9 @@ test("Claude native goal commands use ordinary admission, pause retains the goal
1167
1173
  assert.equal(events.filter(event => event.type === "accepted").length, 3);
1168
1174
  assert.equal(events.some(event => event.type === "goal" && event.goal === null), true);
1169
1175
  const trace = await f.trace();
1176
+ // Resume starts a new process, which first suspends the retained paused checker.
1170
1177
  assert.deepEqual(trace.filter(row => row.frame?.type === "user").map(row => row.frame.message.content),
1171
- ["/goal Tests pass", "/goal Tests pass", "/goal clear"]);
1178
+ ["/goal Tests pass", "/goal clear", "/goal Tests pass", "/goal clear"]);
1172
1179
  const starts = trace.filter(row => row.args?.includes("--print"));
1173
1180
  assert.equal(starts.length, 3);
1174
1181
  assert.equal(starts[1].args.includes("--resume"), true);
@@ -2566,6 +2573,16 @@ async function completedReplyFixture(t, options = {}) {
2566
2573
  prepare: receipt => conversation.prepareCompletedResponse(receipt, { signal: controller.signal }) };
2567
2574
  }
2568
2575
 
2576
+ test("delivery inspection of a turn without runtime metadata returns its duplicate receipt", async t => {
2577
+ const f = await completedReplyFixture(t);
2578
+ try {
2579
+ const plain = await f.plain("pre-runtime-turn");
2580
+ assert.equal(plain.metadata?.runtime, undefined);
2581
+ assert.deepEqual(await f.conversation.inspectDelivery({ messageId: "pre-runtime-turn" }),
2582
+ { status: "accepted", messageId: "pre-runtime-turn", turnId: plain.turnId, duplicate: true });
2583
+ } finally { await f.close(); }
2584
+ });
2585
+
2569
2586
  test("completed Claude prepared reply atomically publishes one plain final across handles and restart", async t => {
2570
2587
  const f = await completedReplyFixture(t);
2571
2588
  try {
@@ -2813,7 +2830,7 @@ for (const kind of ["tool", "reply"]) test(`completed Claude ${kind} wire preser
2813
2830
  assert.equal(saved.assistant.text, raw);
2814
2831
  const prepared = await f.conversation.prepareCompletedResponse(receipt, { signal: new AbortController().signal });
2815
2832
  assert.equal(prepared.text, raw);
2816
- const { readAssistantResponseEnvelope } = await import("../src/server/lib/assistantToolLoop.js");
2833
+ const { readAssistantResponseEnvelope } = await import("../src/server/conversation/boundedToolLoop.js");
2817
2834
  const decoded = readAssistantResponseEnvelope(prepared.text);
2818
2835
  assert.deepEqual(decoded, value);
2819
2836
  if (kind === "tool") assert.equal(JSON.parse(decoded.arguments).value, handover);
@@ -1361,7 +1361,7 @@ test("Claude verified native cleanup retains interrupted checkpoint refusal with
1361
1361
 
1362
1362
  // Retain the original supplied-store/native process fixture and drive it through
1363
1363
  // the actual common runtime, including its completion-only admitted receipt.
1364
- async function suppliedCommonFixture(t, { tools = true, onDeliveryTiming, representation = "canonical", beforeToolWrite, signal, executeTool = async () => ({}) } = {}) {
1364
+ async function suppliedCommonFixture(t, { tools = true, onDeliveryTiming, hostDeliveryStages, representation = "canonical", beforeToolWrite, signal, executeTool = async () => ({}) } = {}) {
1365
1365
  const f = await publishedClaudeFixture(t, { canonicalAdmission: true });
1366
1366
  const storage = createMemoryConversationStorage();
1367
1367
  const transcript = createConversationTranscript({ storage });
@@ -1390,7 +1390,7 @@ async function suppliedCommonFixture(t, { tools = true, onDeliveryTiming, repres
1390
1390
  native: { owner: f.owner, preparation: { cleanup: () => ({ context: f.context, application: {
1391
1391
  readContext: async () => f.context, terminals: { close: async () => ({ ok: true }) }
1392
1392
  } }) } },
1393
- admission() {}, prepareInput: async input => input,
1393
+ admission() {}, prepareInput: async (input, _context, { deliveryTiming } = {}) => { deliveryTiming?.("host-prepared"); return input; },
1394
1394
  read: async () => ({ threadId: f.conversationId, configuration: { model: "native" },
1395
1395
  turn: f.owner.snapshot(f.entry), delivery }),
1396
1396
  readStream: async () => ({ messages: [] }),
@@ -1404,7 +1404,7 @@ async function suppliedCommonFixture(t, { tools = true, onDeliveryTiming, repres
1404
1404
  admissions.push(admission); return actual;
1405
1405
  } } } : {}) };
1406
1406
  const runtime = createConversationRuntime({ ...(tools ? { actions } : {}), authorize: () => true, onDeliveryTiming,
1407
- host: { conversation: () => binding } });
1407
+ hostDeliveryStages, host: { conversation: () => binding } });
1408
1408
  t.after(() => runtime.close());
1409
1409
  const conversation = await runtime.open({ id: "lesson", context, representation });
1410
1410
  f.setBeforeState((entry, event) => {
@@ -1511,20 +1511,20 @@ for (const failedSave of [false, true]) test(`Claude goal waits for common final
1511
1511
 
1512
1512
  test("Claude delivery timing separates native acknowledgement from the held canonical receipt", { timeout: 10_000 }, async t => {
1513
1513
  const timings = [];
1514
- const f = await suppliedCommonFixture(t, { onDeliveryTiming: event => timings.push(event) });
1514
+ const f = await suppliedCommonFixture(t, { onDeliveryTiming: event => timings.push(event), hostDeliveryStages: ["host-prepared"] });
1515
1515
  const writing = Promise.withResolvers();
1516
1516
  const release = Promise.withResolvers();
1517
1517
  f.behavior.beforeAdmit = async () => { writing.resolve(); await release.promise; };
1518
1518
  const sending = f.conversation.send({ messageId: "timed-request", text: "Private message" });
1519
1519
  await writing.promise;
1520
1520
  assert.deepEqual(timings.map(event => event.stage), [
1521
- "received", "preparation-started", "authorized", "history-read", "state-read", "request-saved", "input-prepared",
1522
- "provider-started", "duplicate-checked", "native-history-read", "process-ready", "prompt-rendered", "metadata-ready",
1521
+ "received", "preparation-started", "authorized", "history-read", "state-read", "request-saved", "host-prepared",
1522
+ "input-prepared", "provider-started", "duplicate-checked", "native-history-read", "process-ready", "prompt-rendered", "metadata-ready",
1523
1523
  "runtime-ready", "dispatch-started", "native-acknowledged"
1524
1524
  ]);
1525
1525
  release.resolve();
1526
1526
  assert.equal((await sending).status, "accepted");
1527
- const admitted = timings.map(event => event.stage).slice(16);
1527
+ const admitted = timings.map(event => event.stage).slice(17);
1528
1528
  assert.deepEqual(admitted, ["receipt-written", "state-active", "receipt-persisted", "admission-authorized", "turn-located", "accepted"]);
1529
1529
  await f.frame({ type: "result", subtype: "success", result: "Public answer" });
1530
1530
  await f.conversation.wait();
@@ -2394,6 +2394,26 @@ test("bound Main history budget transfers more than 30 complete messages through
2394
2394
  assert.equal(sent[1], "Prepare the question.");
2395
2395
  });
2396
2396
 
2397
+ test("bound Main treats a restart-interrupted attempt with a saved receipt as delivered, not uncertain", { timeout: 10_000 }, async t => {
2398
+ const f = await suppliedCommonFixture(t, { tools: false });
2399
+ // The application restarted after native admission saved the authored
2400
+ // message, but before the dispatch returned and cleared its attempt marker.
2401
+ const attempt = { messageId: "admitted-before-restart", message: "Check the Preview.", displayMessage: "Check the Preview.",
2402
+ attempted: true, threadId: f.conversationId, seen: {} };
2403
+ await f.binding.state.write({ lastEngine: "claude", engines: { claude: { seen: {}, pending: attempt } } });
2404
+ const unconfirmed = await f.conversation.read();
2405
+ assert.equal(unconfirmed.status, "unconfirmed", "An attempt without a saved receipt stays uncertain");
2406
+ assert.equal(unconfirmed.pendingRequest.messageId, attempt.messageId);
2407
+ await f.binding.transcript.writeUserMessage({ messageId: attempt.messageId, text: attempt.displayMessage });
2408
+ f.binding.transcript.history = async () => [{ id: "000001/user/", version: "v1", role: "user", engineId: "claude",
2409
+ messageId: attempt.messageId, text: attempt.displayMessage }];
2410
+ const settled = await f.conversation.read();
2411
+ assert.equal(settled.status, "ready", "The saved receipt proves delivery, so Send stays available");
2412
+ assert.equal(settled.pendingRequest, null);
2413
+ await sendCommon(f, "after-restart");
2414
+ assert.equal((await f.binding.state.read()).engines.claude.pending, undefined, "The next delivery settles the old marker");
2415
+ });
2416
+
2397
2417
  test("bound Main cannot dispatch or retain a pending prompt when its history budget is unavailable", { timeout: 10_000 }, async t => {
2398
2418
  const f = await suppliedCommonFixture(t, { tools: false, representation: "native" });
2399
2419
  f.binding.transcript.history = async () => [{ id: "earlier", version: "v1", role: "user", text: "Earlier work." }];
@@ -89,3 +89,23 @@ test("transport failure preserves its error code for pending requests", async ()
89
89
  await assert.rejects(result, { code: "assistant_codex_app_server_message_too_large" });
90
90
  assert.equal(client.isOpen(), false);
91
91
  });
92
+
93
+ test("outgoing messages over the limit fail with the transport-limit code", async () => {
94
+ const client = new CodexAppServerJsonRpcClient({ endpoint: "ws://fixture", WebSocketImpl: Socket, maxMessageBytes: 10 });
95
+ await client.connect();
96
+ assert.throws(() => client.send({ payload: "x".repeat(20) }), {
97
+ code: "assistant_codex_app_server_message_too_large",
98
+ message: "Codex app-server message exceeded the configured transport limit."
99
+ });
100
+ });
101
+
102
+ test("message and timeout limits accept only positive integers", () => {
103
+ for (const invalid of [0, -1, 1.5, "abc", Number.NaN]) {
104
+ const client = new CodexAppServerJsonRpcClient({ maxMessageBytes: invalid, requestTimeoutMs: invalid });
105
+ assert.equal(client.maxMessageBytes, Number.POSITIVE_INFINITY);
106
+ assert.equal(client.requestTimeoutMs, 60_000);
107
+ }
108
+ const client = new CodexAppServerJsonRpcClient({ maxMessageBytes: 512, requestTimeoutMs: 25 });
109
+ assert.equal(client.maxMessageBytes, 512);
110
+ assert.equal(client.requestTimeoutMs, 25);
111
+ });
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
5
5
  import path from "node:path";
6
6
  import { createHash } from "node:crypto";
7
7
  import { createConversationRuntime, createFileConversationStorage } from "../src/server/conversation/index.js";
8
- import { createCodexConversationStore } from "../src/server/conversation/agentRun.js";
8
+ import { createCodexConversationStore } from "../src/server/conversation/codexConversationStore.js";
9
9
  import { createReentrantConversationStorage } from "../src/server/conversation/storage.js";
10
10
  import { createConversationTranscript } from "../src/server/conversation/transcript.js";
11
11
  import { createConversationStreams } from "../src/server/conversation/streams.js";
@@ -1229,7 +1229,9 @@ async function fixture(t, options = {}) {
1229
1229
  t.after(async () => {
1230
1230
  try { for (const runtime of runtimes) await runtime.close(); }
1231
1231
  finally {
1232
- if (!options.execution) await execution.close();
1232
+ // A supplied execution host is closed here too (its test may close it
1233
+ // again): its native processes must stop before their folder is removed.
1234
+ await execution.close();
1233
1235
  await rm(directory, { recursive: true, force: true });
1234
1236
  }
1235
1237
  });
@@ -3666,7 +3668,7 @@ for (const kind of ["tool", "reply"]) test(`completed Codex ${kind} wire preserv
3666
3668
  assert.equal(run.outerTurnId, receipt.messageId);
3667
3669
  const prepared = await f.conversation.prepareCompletedResponse(receipt, { signal: new AbortController().signal });
3668
3670
  assert.equal(prepared.text, raw);
3669
- const { readAssistantResponseEnvelope } = await import("../src/server/lib/assistantToolLoop.js");
3671
+ const { readAssistantResponseEnvelope } = await import("../src/server/conversation/boundedToolLoop.js");
3670
3672
  const decoded = readAssistantResponseEnvelope(prepared.text);
3671
3673
  assert.deepEqual(decoded, value);
3672
3674
  if (kind === "tool") assert.equal(JSON.parse(decoded.arguments).value, handover);
@@ -0,0 +1,51 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createCodexConversationCommands } from "../src/server/conversation/codexConversations.js";
4
+
5
+ const turnOutcomes = { USER_CANCELLED: "user_cancelled" };
6
+
7
+ function persistentConversationFixture() {
8
+ const checkpoints = [];
9
+ const listeners = new Set();
10
+ const emit = (notification) => listeners.forEach((listener) => listener(notification));
11
+ const completed = (status) => emit({ method: "turn/completed", params: { threadId: "thread", turn: { id: "turn-1", status } } });
12
+ const provider = {
13
+ subscribe(listener) {
14
+ listeners.add(listener);
15
+ return () => listeners.delete(listener);
16
+ },
17
+ async readThreadStatus() { return {}; },
18
+ // Codex reports the interrupted turn while it is stopping.
19
+ async stopThreadForObservationLoss() { completed("interrupted"); }
20
+ };
21
+ const commands = createCodexConversationCommands({
22
+ async checkpoint(_sessionId, input) { checkpoints.push(input); },
23
+ conversationPreparation: { async context() { return { result: { provider } }; } },
24
+ namespace: (sessionId) => sessionId,
25
+ turnOutcomes
26
+ });
27
+ const state = commands.observeCodexConversation("session", "thread", provider);
28
+ const startTurn = () => emit({ method: "turn/started", params: { threadId: "thread", turn: { id: "turn-1", status: "inProgress" } } });
29
+ return { checkpoints, commands, completed, startTurn, state };
30
+ }
31
+
32
+ test("a temporary turn the person stops reaches its checkpoint as user cancelled", async () => {
33
+ const f = persistentConversationFixture();
34
+ f.startTurn();
35
+ await f.commands.stopCodexAppServerConversation("session", { conversationId: "thread", persistent: true });
36
+ assert.equal(f.checkpoints.length, 1);
37
+ assert.equal(f.checkpoints[0].turnOutcome, "user_cancelled");
38
+ assert.equal(f.checkpoints[0].status, "interrupted");
39
+ // The outcome belongs to the stopped turn only.
40
+ f.startTurn();
41
+ f.completed("completed");
42
+ assert.equal(f.checkpoints[1].turnOutcome, "");
43
+ });
44
+
45
+ test("closing temporary conversations leaves the turn outcome to its status", async () => {
46
+ const f = persistentConversationFixture();
47
+ f.startTurn();
48
+ await f.commands.closeCodexAppServerConversations("session");
49
+ assert.equal(f.checkpoints.length, 1);
50
+ assert.equal(f.checkpoints[0].turnOutcome, "");
51
+ });
@@ -5,7 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import test from "node:test";
7
7
  import { prepareCodexModelCatalog } from "../src/server/conversation/codexConfiguration.js";
8
- import { createCodexAppServerModelCatalogCache, codexAppServerProviderConnectionGeneration } from "../src/server/conversation/codexProvider.js";
8
+ import { createCodexAppServerModelCatalogCache, codexAppServerProviderConnectionGeneration } from "../src/server/conversation/codexModelCatalog.js";
9
9
  import { createCodexAppServerProviderOwner } from "../src/server/conversation/codexProviderOwner.js";
10
10
 
11
11
  async function fixture(t, source) {
@@ -0,0 +1,51 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+ import {
7
+ codexProviderCatalogPath,
8
+ codexProviderFileConfiguration,
9
+ copyCodexProviderHome
10
+ } from "../src/server/conversation/codexConfiguration.js";
11
+
12
+ const provider = {
13
+ id: "fixture",
14
+ label: "Fixture",
15
+ baseUrl: "https://provider.invalid/v1",
16
+ webSearch: false,
17
+ models: [{ id: "fixture-model", defaultThinking: "medium" }]
18
+ };
19
+
20
+ async function homes(t) {
21
+ const root = await mkdtemp(path.join(os.tmpdir(), "codex-provider-home-"));
22
+ t.after(() => rm(root, { force: true, recursive: true }));
23
+ const sourceHome = path.join(root, "source");
24
+ const targetHome = path.join(root, "target");
25
+ await Promise.all([mkdir(sourceHome), mkdir(targetHome)]);
26
+ return { sourceHome, targetHome };
27
+ }
28
+
29
+ test("a copied provider home names its own catalogue", async t => {
30
+ const { sourceHome, targetHome } = await homes(t);
31
+ await writeFile(path.join(sourceHome, "config.toml"), codexProviderFileConfiguration(provider, "secret", sourceHome));
32
+ await writeFile(codexProviderCatalogPath(sourceHome), "{\"models\":[]}");
33
+
34
+ await copyCodexProviderHome(sourceHome, targetHome);
35
+
36
+ assert.equal(
37
+ await readFile(path.join(targetHome, "config.toml"), "utf8"),
38
+ codexProviderFileConfiguration(provider, "secret", targetHome)
39
+ );
40
+ assert.equal(await readFile(codexProviderCatalogPath(targetHome), "utf8"), "{\"models\":[]}");
41
+ });
42
+
43
+ test("a provider config that does not name its own catalogue is refused, not copied", async t => {
44
+ const { sourceHome, targetHome } = await homes(t);
45
+ const elsewhere = path.join(path.dirname(sourceHome), "elsewhere");
46
+ await writeFile(path.join(sourceHome, "config.toml"), codexProviderFileConfiguration(provider, "secret", elsewhere));
47
+ await writeFile(codexProviderCatalogPath(sourceHome), "{}");
48
+
49
+ await assert.rejects(copyCodexProviderHome(sourceHome, targetHome), /does not name its own model catalogue/);
50
+ await assert.rejects(readFile(path.join(targetHome, "config.toml")), { code: "ENOENT" });
51
+ });
@@ -0,0 +1,71 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createCodexProviderSelection } from "../src/server/conversation/codexProviderSelection.js";
4
+
5
+ function fixture() {
6
+ const calls = { contexts: [], ensured: [], remembered: [], acquired: [] };
7
+ const provider = {
8
+ ensureRuntime: () => "runtime"
9
+ };
10
+ const providerSessions = {
11
+ managed: new Map(),
12
+ owner: {
13
+ providers: new Map(),
14
+ async ensureSession(connection, threadId) {
15
+ calls.ensured.push({ sessionId: connection.sessionId, threadId });
16
+ return provider;
17
+ },
18
+ async acquireRuntime(input) {
19
+ calls.acquired.push(input.providerKey);
20
+ return input.operation();
21
+ }
22
+ },
23
+ context(sessionId, providerOptions) {
24
+ calls.contexts.push(sessionId);
25
+ return {
26
+ sessionId,
27
+ providerOptions,
28
+ providerKey: `key:${sessionId}`,
29
+ runOwner: {
30
+ runtimeLifecycle: {
31
+ rememberManagedSession: (providerKey, record) => calls.remembered.push({ providerKey, ...record })
32
+ }
33
+ }
34
+ };
35
+ }
36
+ };
37
+ const selection = createCodexProviderSelection({
38
+ providerSessions,
39
+ turnState: () => ({ active: false }),
40
+ helperLifecycle: null,
41
+ helperOwnershipError: null,
42
+ modelCatalogCacheMs: 1,
43
+ errorPrefix: ""
44
+ });
45
+ return { calls, selection };
46
+ }
47
+
48
+ test("session providers and managed records use the normalized session id", async () => {
49
+ const { calls, selection } = fixture();
50
+ await selection.ensureSessionProvider(" s1 ", { mode: "a" }, "thread-1");
51
+ selection.rememberManagedSession(" s1 ", { mode: "a" }, { executionRoot: "/root", workdir: "/work", threadId: "thread-1" });
52
+
53
+ assert.deepEqual(calls.ensured, [{ sessionId: "s1", threadId: "thread-1" }]);
54
+ assert.deepEqual(calls.remembered, [{
55
+ providerKey: "key:s1",
56
+ providerOptions: { mode: "a" },
57
+ sessionId: "s1",
58
+ executionRoot: "/root",
59
+ threadId: "thread-1",
60
+ workdir: "/work"
61
+ }]);
62
+ assert.equal(selection.codexAppServerSessionObserverOptions(" s1 ", {}).providerKey, "key:s1");
63
+ });
64
+
65
+ test("the visible terminal runtime acquires the provider key of the normalized session", async () => {
66
+ const { calls, selection } = fixture();
67
+ await selection.codexAppServerRuntimeForVisibleTerminal(" s1 ", {}, { workdir: "" }, {}, async () => ({}));
68
+ assert.deepEqual(calls.ensured, [{ sessionId: "s1", threadId: undefined }]);
69
+ assert.deepEqual(calls.acquired, ["key:s1"]);
70
+ assert.ok(calls.contexts.every(sessionId => sessionId === "s1"));
71
+ });