@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
package/test/codexTurn.test.js
CHANGED
|
@@ -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].
|
|
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.
|
|
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
|
|
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 {
|
|
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 {
|
|
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(
|
|
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, []);
|