@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.
- package/examples/conversation/package.json +5 -5
- package/package.json +5 -5
- package/src/client/conversation/AssistantTranscript.vue +26 -29
- package/src/client/conversation/LongTextPreviewBlocks.vue +11 -0
- package/src/client/conversation/messageDelivery.js +8 -2
- package/src/client/conversation/useAssistantAttachments.js +1 -1
- package/src/server/conversation/agentRun.js +12 -198
- package/src/server/conversation/attachments.js +6 -5
- package/src/server/conversation/boundedToolLoop.js +160 -0
- package/src/server/conversation/claudeHistory.js +20 -16
- package/src/server/conversation/claudeProcess.js +2 -2
- package/src/server/conversation/claudeTurn.js +51 -40
- package/src/server/conversation/codexAuthPreflight.js +104 -0
- package/src/server/conversation/codexClient.js +11 -16
- package/src/server/conversation/codexConfiguration.js +112 -98
- package/src/server/conversation/codexConversationStore.js +189 -0
- package/src/server/conversation/codexConversations.js +47 -44
- package/src/server/conversation/codexDetachedTurn.js +12 -11
- package/src/server/conversation/codexEvents.js +95 -99
- package/src/server/conversation/codexGoalCommands.js +1 -1
- package/src/server/conversation/codexHelperExecution.js +3 -13
- package/src/server/conversation/codexHelperThreadLedger.js +7 -13
- package/src/server/conversation/codexHelperThreadRecovery.js +2 -16
- package/src/server/conversation/codexHelperThreads.js +5 -6
- package/src/server/conversation/codexHistoryAdapter.js +32 -26
- package/src/server/conversation/codexMessageCommands.js +22 -22
- package/src/server/conversation/codexMessageDelivery.js +12 -7
- package/src/server/conversation/codexModelCatalog.js +97 -0
- package/src/server/conversation/codexNotificationObserver.js +24 -40
- package/src/server/conversation/codexNotificationQueue.js +2 -3
- package/src/server/conversation/codexOutput.js +20 -27
- package/src/server/conversation/codexProcess.js +123 -127
- package/src/server/conversation/codexProcessLeader.js +3 -3
- package/src/server/conversation/codexProvider.js +31 -614
- package/src/server/conversation/codexProviderOwner.js +2 -2
- package/src/server/conversation/codexProviderSelection.js +38 -40
- package/src/server/conversation/codexRenewalCommands.js +13 -10
- package/src/server/conversation/codexRenewalThreads.js +412 -0
- package/src/server/conversation/codexRunJournal.js +24 -42
- package/src/server/conversation/codexRuntimeLifecycle.js +19 -15
- package/src/server/conversation/codexSessionCleanup.js +11 -10
- package/src/server/conversation/codexSessionReconciliation.js +6 -6
- package/src/server/conversation/codexThreadReadiness.js +19 -33
- package/src/server/conversation/codexThreadStatus.js +3 -3
- package/src/server/conversation/codexTurn.js +124 -297
- package/src/server/conversation/codexTurnRecovery.js +49 -28
- package/src/server/conversation/codexTurnState.js +34 -13
- package/src/server/conversation/commandWrapper.js +3 -0
- package/src/server/conversation/deliveryTiming.js +14 -5
- package/src/server/conversation/index.js +1 -0
- package/src/server/conversation/localExecution.js +13 -17
- package/src/server/conversation/normalize.js +8 -3
- package/src/server/conversation/openCodeClient.js +9 -10
- package/src/server/conversation/openCodeProcess.js +9 -5
- package/src/server/conversation/openCodeRuntime.js +10 -11
- package/src/server/conversation/openCodeTurn.js +43 -29
- package/src/server/conversation/providers/claudeDriver.js +10 -16
- package/src/server/conversation/providers/codexDriver.js +27 -43
- package/src/server/conversation/providers/opencodeDriver.js +17 -42
- package/src/server/conversation/providers/scoped.js +48 -4
- package/src/server/conversation/runtime.js +50 -49
- package/src/server/conversation/runtimeStateUpgrade.js +34 -22
- package/src/server/conversation/transcript.js +10 -10
- package/src/server/hosts/codexConfiguration.js +1 -0
- package/src/server/hosts/codexProvider.js +8 -6
- package/src/server/index.js +2 -1
- package/src/server/lib/assistantToolLoop.js +1 -153
- package/src/server/lib/serviceToolCatalog.js +17 -42
- package/src/shared/assistantResource.js +4 -2
- package/src/shared/conversation/attachments.js +4 -0
- package/src/shared/conversation/longTextBlocks.js +54 -19
- package/src/shared/conversation/numberedQuestionSugar.js +17 -10
- package/src/shared/conversation/pagination.js +4 -6
- package/src/shared/conversation/patches.js +1 -4
- package/src/shared/conversation/streaming.js +3 -11
- package/src/shared/conversation/turns.js +11 -0
- package/src/shared/index.js +1 -0
- package/test/assistantBoundedToolLoop.test.js +2 -2
- package/test/assistantResource.test.js +4 -4
- package/test/assistantScroll.browser.test.js +29 -6
- package/test/assistantSettingsResource.test.js +2 -2
- package/test/claudeConversationSession.test.js +20 -3
- package/test/claudeTurn.test.js +27 -7
- package/test/codexClient.test.js +20 -0
- package/test/codexConversationSession.test.js +5 -3
- package/test/codexConversationsCheckpoint.test.js +51 -0
- package/test/codexModelCatalog.test.js +1 -1
- package/test/codexProviderHome.test.js +51 -0
- package/test/codexProviderSelection.test.js +71 -0
- package/test/codexTurn.test.js +222 -6
- package/test/conversationAgentRun.test.js +2 -1
- package/test/conversationAttachments.test.js +27 -2
- package/test/conversationDeliveryTiming.test.js +21 -0
- package/test/conversationNormalize.test.js +11 -0
- package/test/fixtures/nestedListQuestionMessage.md +37 -0
- package/test/frameworkActions.test.js +2 -2
- package/test/longTextBlocks.test.js +102 -0
- package/test/messageDelivery.test.js +7 -1
- package/test/openCodeClient.test.js +20 -0
- package/test/openCodeRuntime.test.js +1 -1
- package/test/openCodeTurn.test.js +90 -0
- package/test/opencodeConversationSession.test.js +1 -1
- 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
|
|
1239
|
-
await
|
|
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
|
|
1245
|
-
|
|
1246
|
-
|
|
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 {
|
|
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 =
|
|
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")
|
|
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/
|
|
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);
|
package/test/claudeTurn.test.js
CHANGED
|
@@ -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", "
|
|
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(
|
|
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." }];
|
package/test/codexClient.test.js
CHANGED
|
@@ -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/
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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
|
+
});
|