@jskit-ai/assistant-core 0.2.2 → 0.2.4
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/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
- package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
- package/locales/en.json +4 -2
- package/package.json +5 -5
- package/src/client/conversation/AssistantConversationElement.vue +8 -1
- package/src/client/conversation/AssistantGoalControl.vue +2 -2
- package/src/client/conversation/AssistantTranscript.vue +49 -12
- package/src/client/conversation/messageDelivery.js +23 -6
- package/src/client/lib/assistantApi.js +5 -0
- package/src/server/conversation/claudeProcess.js +105 -58
- package/src/server/conversation/claudeTurn.js +179 -59
- package/src/server/conversation/codexAuthPreflight.js +3 -0
- package/src/server/conversation/codexConfiguration.js +42 -4
- package/src/server/conversation/codexConversations.js +256 -114
- package/src/server/conversation/codexEvents.js +16 -3
- package/src/server/conversation/codexHelperThreadRecovery.js +17 -4
- package/src/server/conversation/codexHelperThreads.js +10 -1
- package/src/server/conversation/codexHomeStorage.js +69 -0
- package/src/server/conversation/codexProcess.js +7 -2
- package/src/server/conversation/codexProvider.js +57 -46
- package/src/server/conversation/codexProviderOwner.js +234 -29
- package/src/server/conversation/codexProviderSelection.js +13 -3
- package/src/server/conversation/codexRuntimeLifecycle.js +56 -4
- package/src/server/conversation/codexSessionCleanup.js +67 -20
- package/src/server/conversation/codexSessionReconciliation.js +18 -4
- package/src/server/conversation/codexTurn.js +9 -2
- package/src/server/conversation/continuity.js +13 -1
- package/src/server/conversation/openCodeClient.js +34 -0
- package/src/server/conversation/openCodeProcess.js +37 -5
- package/src/server/conversation/openCodeRuntime.js +225 -64
- package/src/server/conversation/providers/claudeDriver.js +3 -3
- package/src/server/conversation/providers/codexDriver.js +10 -4
- package/src/server/conversation/providers/opencodeDriver.js +4 -2
- package/src/server/conversation/runtime.js +72 -2
- package/src/server/hosts/codexConfiguration.js +4 -0
- package/test/assistantScroll.browser.test.js +169 -10
- package/test/claudeLifecycle.test.js +293 -0
- package/test/claudeProcess.test.js +195 -51
- package/test/claudeTurn.test.js +174 -3
- package/test/codexAccountChange.test.js +261 -0
- package/test/codexConversationsCheckpoint.test.js +3 -0
- package/test/codexEvents.test.js +23 -1
- package/test/codexHomeStorage.test.js +57 -0
- package/test/codexInteractiveArguments.test.js +9 -0
- package/test/codexLifecycleIsolation.test.js +441 -0
- package/test/codexModelCatalog.test.js +48 -2
- package/test/codexProcess.test.js +21 -0
- package/test/codexTemporaryProviderRelease.test.js +180 -0
- package/test/codexTurn.test.js +21 -1
- package/test/conversationNativeStorage.test.js +57 -0
- package/test/conversationRuntime.test.js +66 -0
- package/test/conversationSession.test.js +4 -8
- package/test/messageDelivery.browser.test.js +18 -2
- package/test/messageDelivery.test.js +34 -1
- package/test/openCodeClient.test.js +31 -0
- package/test/openCodeConnectionChange.test.js +166 -0
- package/test/openCodeProcess.test.js +36 -0
- package/test/openCodeRuntime.test.js +25 -78
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createCodexAppServerProviderOwner } from "../src/server/conversation/codexProviderOwner.js";
|
|
4
|
+
import { createCodexConversationCommands } from "../src/server/conversation/codexConversations.js";
|
|
5
|
+
import { createCodexSessionCleanup } from "../src/server/conversation/codexSessionCleanup.js";
|
|
6
|
+
|
|
7
|
+
// A Codex app-server runs only while it is used: reading a conversation's
|
|
8
|
+
// history or status, or stopping it, borrows the process; a running Temporary
|
|
9
|
+
// turn or goal holds it; and closing a project stops every one of its own.
|
|
10
|
+
|
|
11
|
+
async function eventually(check, message) {
|
|
12
|
+
const deadline = Date.now() + 2_000;
|
|
13
|
+
while (!check()) {
|
|
14
|
+
if (Date.now() > deadline) assert.fail(message);
|
|
15
|
+
await new Promise(resolve => setTimeout(resolve, 5));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function settle(ms) {
|
|
20
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// One native process per runtime directory, as the real app-server has per
|
|
24
|
+
// account and runtime scope. Each provider connection reports its own thread.
|
|
25
|
+
function codexFixture(t, { idleMs = 20 } = {}) {
|
|
26
|
+
const runtimes = new Map();
|
|
27
|
+
const listeners = new Set();
|
|
28
|
+
const runtimeFor = runtimeDir => {
|
|
29
|
+
if (!runtimes.has(runtimeDir)) runtimes.set(runtimeDir, { running: false, starts: 0, stops: 0 });
|
|
30
|
+
return runtimes.get(runtimeDir);
|
|
31
|
+
};
|
|
32
|
+
const owner = createCodexAppServerProviderOwner({ transientIdleMs: idleMs, providerFactory: ({ runtimeDir }) => {
|
|
33
|
+
const runtime = runtimeFor(runtimeDir);
|
|
34
|
+
return {
|
|
35
|
+
observationFailure: null,
|
|
36
|
+
isHelperProvider: () => false,
|
|
37
|
+
isAvailable: () => runtime.running,
|
|
38
|
+
async ensureAvailable() {
|
|
39
|
+
if (!runtime.running) { runtime.running = true; runtime.starts += 1; }
|
|
40
|
+
return { ok: true };
|
|
41
|
+
},
|
|
42
|
+
async stopRuntime() { runtime.running = false; runtime.stops += 1; return { stopped: true }; },
|
|
43
|
+
close() {},
|
|
44
|
+
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
|
45
|
+
async readThreadStatus(threadId) { return { raw: { id: threadId, historyMode: "paginated", status: "idle" } }; },
|
|
46
|
+
async listThreadTurns() { return { data: [{ id: "turn-1", status: "completed", items: [] }] }; },
|
|
47
|
+
async readGoal() { return { goal: null }; },
|
|
48
|
+
async resumeThread() { return {}; },
|
|
49
|
+
async sendTurn() { return { id: "turn-2", status: "inProgress" }; },
|
|
50
|
+
async stopThreadForObservationLoss() {}
|
|
51
|
+
};
|
|
52
|
+
} });
|
|
53
|
+
t.after(() => owner.beginShutdown());
|
|
54
|
+
const sessionKey = sessionId => `codex:project-a:${sessionId}`;
|
|
55
|
+
const connection = (sessionId, keyPrefix = "codex:project-a:") => ({
|
|
56
|
+
sessionId, providerKey: `provider:${sessionId}`, providerOptions: { runtimeDir: `/tmp/jskit-codex-release-${sessionId}` },
|
|
57
|
+
keyFields: key => ({ sessionId: key.slice("provider:".length) }), assertAdmission() {}, closing: false,
|
|
58
|
+
runOwner: { runtimeLifecycle: { rememberManagedSession() {}, managedThreadId: () => "" } },
|
|
59
|
+
async prepare() {
|
|
60
|
+
return { parameters: { runtimeDir: `/tmp/jskit-codex-release-${sessionId}` }, owner: { sessionKey: `${keyPrefix}${sessionId}` },
|
|
61
|
+
resources: null, controls: { available: false, subscribe: () => () => {} }, runInContext: operation => operation() };
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
const commands = createCodexConversationCommands({
|
|
65
|
+
async checkpoint() {},
|
|
66
|
+
codexAppServerConversationMessageId: (...parts) => parts.join(":"),
|
|
67
|
+
codexAppServerStreamMessage: () => ({}),
|
|
68
|
+
conversationPreparation: {
|
|
69
|
+
async context() {
|
|
70
|
+
return { context: { session: {} }, helperTurn: false, projectRuntimeRoot: "/runtime", managedIdentity: {},
|
|
71
|
+
providerOptions: {}, project: provider => ({ provider, workdir: "/work", agentSettings: {} }) };
|
|
72
|
+
},
|
|
73
|
+
admissionError: () => null,
|
|
74
|
+
failure(error) { return { ok: false, error: error.message }; },
|
|
75
|
+
response: text => ({ text }),
|
|
76
|
+
execution() {
|
|
77
|
+
return { executionProfile: null, threadPreparation: { settings: () => ({}) }, authorized: { turnSettings: {} },
|
|
78
|
+
readOnly: false, onEvent: null, actorMetadata: {}, projectResult: result => result };
|
|
79
|
+
},
|
|
80
|
+
isolation: { assertCompatibility() {} }
|
|
81
|
+
},
|
|
82
|
+
ensureCodexAppServerProviderForActiveTurn: async () => null,
|
|
83
|
+
errorPrefix: "",
|
|
84
|
+
helperLifecycle: { async assertOrdinaryThreadAvailable() {}, inspectControl: () => undefined },
|
|
85
|
+
namespace: sessionId => sessionKey(sessionId),
|
|
86
|
+
providerSessions: { owner },
|
|
87
|
+
ensureSessionProvider: sessionId => owner.ensureSession(connection(sessionId)),
|
|
88
|
+
borrowSessionProvider: sessionId => owner.borrowSession(connection(sessionId)),
|
|
89
|
+
turnOutcomes: { USER_CANCELLED: "cancelled" }
|
|
90
|
+
});
|
|
91
|
+
const emit = notification => [...listeners].forEach(listener => listener(notification));
|
|
92
|
+
return { owner, commands, connection, emit, runtime: runtimeFor("/tmp/jskit-codex-release-session-1"), runtimeFor };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
test("reading a Temporary chat's history leaves no Codex app-server running", async t => {
|
|
96
|
+
const f = codexFixture(t);
|
|
97
|
+
const read = await f.commands.readCodexAppServerConversation("session-1", { conversationId: "chat-1", persistent: true });
|
|
98
|
+
assert.equal(read.ok, true);
|
|
99
|
+
assert.equal(read.status, "completed");
|
|
100
|
+
assert.equal(f.runtime.running, true, "the read itself uses the process");
|
|
101
|
+
await eventually(() => !f.runtime.running, "an unused history read kept Codex running");
|
|
102
|
+
assert.equal(f.runtime.stops, 1);
|
|
103
|
+
assert.deepEqual([...f.owner.providers.keys()], []);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("consecutive Temporary chat reads share one Codex process and the last releases it", async t => {
|
|
107
|
+
const f = codexFixture(t, { idleMs: 40 });
|
|
108
|
+
for (let read = 0; read < 3; read += 1) {
|
|
109
|
+
await f.commands.readCodexAppServerConversation("session-1", { conversationId: "chat-1", persistent: true });
|
|
110
|
+
}
|
|
111
|
+
assert.equal(f.runtime.starts, 1);
|
|
112
|
+
await eventually(() => !f.runtime.running, "the last read did not release Codex");
|
|
113
|
+
assert.equal(f.runtime.stops, 1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("stopping an idle Temporary chat leaves no Codex app-server running", async t => {
|
|
117
|
+
const f = codexFixture(t);
|
|
118
|
+
const stopped = await f.commands.stopCodexAppServerConversation("session-1", { conversationId: "chat-1", persistent: true });
|
|
119
|
+
assert.equal(stopped.status, "interrupted");
|
|
120
|
+
await eventually(() => !f.runtime.running, "a Stop of idle work kept Codex running");
|
|
121
|
+
assert.deepEqual([...f.owner.providers.keys()], []);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("a running Temporary turn keeps Codex; it stops a short idle after the turn ends", async t => {
|
|
125
|
+
const f = codexFixture(t);
|
|
126
|
+
const started = await f.commands.startCodexAppServerConversationTurn("session-1", {
|
|
127
|
+
conversationId: "chat-1", persistent: true, message: "Explain this file", messageId: "message-1"
|
|
128
|
+
});
|
|
129
|
+
assert.equal(started.status, "inProgress");
|
|
130
|
+
// A history read while the turn runs must not release its process either.
|
|
131
|
+
await f.commands.readCodexAppServerConversation("session-1", { conversationId: "chat-1", persistent: true });
|
|
132
|
+
f.emit({ method: "turn/started", params: { threadId: "chat-1", turn: { id: "turn-2", status: "inProgress" } } });
|
|
133
|
+
await settle(80);
|
|
134
|
+
assert.equal(f.runtime.running, true, "the running turn's process was stopped");
|
|
135
|
+
assert.equal(f.runtime.stops, 0);
|
|
136
|
+
f.emit({ method: "turn/completed", params: { threadId: "chat-1", turn: { id: "turn-2", status: "completed" } } });
|
|
137
|
+
await eventually(() => !f.runtime.running, "the finished turn kept Codex running");
|
|
138
|
+
assert.equal(f.runtime.stops, 1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("an active Temporary goal keeps Codex between its turns until the goal ends", async t => {
|
|
142
|
+
const f = codexFixture(t);
|
|
143
|
+
await f.commands.startCodexAppServerConversationTurn("session-1", {
|
|
144
|
+
conversationId: "chat-1", persistent: true, message: "Finish the migration", messageId: "message-1"
|
|
145
|
+
});
|
|
146
|
+
f.emit({ method: "thread/goal/updated", params: { threadId: "chat-1", goal: { status: "active" } } });
|
|
147
|
+
f.emit({ method: "turn/completed", params: { threadId: "chat-1", turn: { id: "turn-2", status: "completed" } } });
|
|
148
|
+
await settle(80);
|
|
149
|
+
assert.equal(f.runtime.running, true, "the goal's process was stopped between its turns");
|
|
150
|
+
f.emit({ method: "thread/goal/updated", params: { threadId: "chat-1", goal: { status: "complete" } } });
|
|
151
|
+
await eventually(() => !f.runtime.running, "the finished goal kept Codex running");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("closing a project stops a Codex app-server its Temporary chat read, and only that project's", async t => {
|
|
155
|
+
const f = codexFixture(t, { idleMs: 60_000 });
|
|
156
|
+
await f.commands.readCodexAppServerConversation("session-1", { conversationId: "chat-1", persistent: true });
|
|
157
|
+
// Another project's session keeps its own conversation.
|
|
158
|
+
const other = f.runtimeFor("/tmp/jskit-codex-release-session-9");
|
|
159
|
+
await f.owner.ensureSession(f.connection("session-9", "codex:project-b:"));
|
|
160
|
+
assert.equal(f.runtime.running, true);
|
|
161
|
+
assert.equal(other.running, true);
|
|
162
|
+
const cleanup = createCodexSessionCleanup({
|
|
163
|
+
namespace: sessionId => `codex:project-a:${sessionId}`,
|
|
164
|
+
runOwner: {},
|
|
165
|
+
providerSessions: { owner: f.owner, managed: new Map(), keyFields: key => ({ sessionId: key.slice("provider:".length) }) },
|
|
166
|
+
sessionClosures: new Map(),
|
|
167
|
+
helperLifecycle: { async restoreThreads() { return { ok: true, failed: [] }; }, records: () => [] },
|
|
168
|
+
journal: { clearSessionRecoveryTimers() {} },
|
|
169
|
+
conversations: f.commands,
|
|
170
|
+
debugLog() {},
|
|
171
|
+
debugError: error => error?.message
|
|
172
|
+
});
|
|
173
|
+
const closed = await cleanup.closeProject({ projectContextRoot: "/project-a" }, {
|
|
174
|
+
helpers: Promise.resolve({ projectRuntimeRoot: "/runtime", context: {} })
|
|
175
|
+
});
|
|
176
|
+
assert.equal(closed.ok, true);
|
|
177
|
+
assert.equal(f.runtime.running, false, "project close left the Temporary chat's Codex running");
|
|
178
|
+
assert.equal(other.running, true, "another project's Codex was stopped");
|
|
179
|
+
assert.deepEqual([...f.owner.providers.keys()], ["provider:session-9"]);
|
|
180
|
+
});
|
package/test/codexTurn.test.js
CHANGED
|
@@ -491,7 +491,7 @@ test("session cleanup owns its single-flight closures and restricted record with
|
|
|
491
491
|
return { exitFailure: null };
|
|
492
492
|
}
|
|
493
493
|
} },
|
|
494
|
-
helperLifecycle: {
|
|
494
|
+
helperLifecycle: { async restoreThreads() { return { failed: [], ok: true }; }, assertRetired() {}, async retireAll() {} },
|
|
495
495
|
journal: { clearSessionRecoveryTimers() {} },
|
|
496
496
|
conversations: { async closeCodexAppServerConversations() {} },
|
|
497
497
|
debugLog() {}, debugError: error => error
|
|
@@ -3232,6 +3232,26 @@ test("native changeover treats a proven not-admitted attempt as not sent", async
|
|
|
3232
3232
|
}
|
|
3233
3233
|
});
|
|
3234
3234
|
|
|
3235
|
+
test("Cancel discards an unconfirmed native attempt so the next message is neither blocked nor replayed", async () => {
|
|
3236
|
+
const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
|
|
3237
|
+
let value = { lastEngine: "codex", engines: { codex: { seen: {}, pending: { messageId: "cancelled-request", message: "Prepared prompt",
|
|
3238
|
+
displayMessage: "Original words.", displayAttachments: [], attachmentIds: [], seen: {}, attempted: true, threadId: "thread" } } } };
|
|
3239
|
+
const sent = [];
|
|
3240
|
+
const changeover = createConversationChangeover({
|
|
3241
|
+
state: { async read() { return structuredClone(value); }, async write(next) { value = structuredClone(next); } },
|
|
3242
|
+
transcript: { async hasMessage() { return false; } },
|
|
3243
|
+
agent: { async inspectMessageAdmission() { return { admission: "unknown" }; },
|
|
3244
|
+
async sendMessage(input) { sent.push(input.messageId); return { ok: true, delivered: true }; } }
|
|
3245
|
+
});
|
|
3246
|
+
assert.equal((await changeover.send({ engineId: "codex", messages: [], input: { messageId: "next", message: "Next." } })).code,
|
|
3247
|
+
"conversation_changeover_delivery_unconfirmed", "An unknown attempt blocks the next message until it is resolved");
|
|
3248
|
+
assert.equal(await changeover.discard({ messageId: "other-request" }), false);
|
|
3249
|
+
assert.equal(await changeover.discard({ messageId: "cancelled-request" }), true);
|
|
3250
|
+
assert.equal(value.engines.codex.pending, undefined);
|
|
3251
|
+
assert.equal((await changeover.send({ engineId: "codex", messages: [], input: { messageId: "next", message: "Next." } })).delivered, true);
|
|
3252
|
+
assert.deepEqual(sent, ["next"], "The cancelled attempt is never replayed");
|
|
3253
|
+
});
|
|
3254
|
+
|
|
3235
3255
|
test("initial native seed limits preserve generic history, full returning corrections and delivery ownership", async () => {
|
|
3236
3256
|
const { createConversationChangeover } = await import("../src/server/conversation/continuity.js");
|
|
3237
3257
|
const attachment = { attachmentId: "retained-file", name: "retained.txt" };
|
|
@@ -250,3 +250,60 @@ test("a conversation that starts after an OpenCode storage read keeps the server
|
|
|
250
250
|
await owner.release(open);
|
|
251
251
|
assert.equal(server.running, false);
|
|
252
252
|
});
|
|
253
|
+
|
|
254
|
+
// The inventory keeps only thread ids. Long first prompts in each thread/list
|
|
255
|
+
// preview are not retained, so they cannot make a valid inventory fail.
|
|
256
|
+
function codexThreadListing(pages) {
|
|
257
|
+
const requests = [];
|
|
258
|
+
const client = {
|
|
259
|
+
async request(method, params) {
|
|
260
|
+
assert.equal(method, "thread/list");
|
|
261
|
+
requests.push(params);
|
|
262
|
+
const index = params.cursor ? Number(params.cursor) : 0;
|
|
263
|
+
const archived = params.archived === true;
|
|
264
|
+
const data = archived ? [] : pages[index] || [];
|
|
265
|
+
const nextCursor = !archived && index + 1 < pages.length ? String(index + 1) : null;
|
|
266
|
+
return { data, nextCursor };
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
const inventory = { activeClient: async () => client, runRequest: (operation) => operation(), errorPrefix: "vibe64_" };
|
|
270
|
+
return { inventory, requests };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function nativeThread(id, { cwd = "/source", previewBytes = 0 } = {}) {
|
|
274
|
+
return { id, cwd, preview: "p".repeat(previewBytes), status: { type: "notLoaded" } };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
test("Codex native existence checks a home whose thread previews exceed any byte budget", async () => {
|
|
278
|
+
const pages = [
|
|
279
|
+
Array.from({ length: 100 }, (_, index) => nativeThread(`thread-${index}`, { previewBytes: 30 * 1024 })),
|
|
280
|
+
[nativeThread("wanted", { previewBytes: 70 * 1024 })]
|
|
281
|
+
];
|
|
282
|
+
const { inventory } = codexThreadListing(pages);
|
|
283
|
+
const { CodexAppServerAgentProvider } = await import("../src/server/conversation/codexProvider.js");
|
|
284
|
+
assert.equal(await CodexAppServerAgentProvider.prototype.nativeThreadExists.call(inventory, "wanted"), true);
|
|
285
|
+
assert.equal(await CodexAppServerAgentProvider.prototype.nativeThreadExists.call(inventory, "missing"), false);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("Codex native existence searches more threads than an inventory retains", async () => {
|
|
289
|
+
const pages = Array.from({ length: 12 }, (_, page) =>
|
|
290
|
+
Array.from({ length: 100 }, (_, index) => nativeThread(`thread-${page}-${index}`)));
|
|
291
|
+
const { inventory, requests } = codexThreadListing(pages);
|
|
292
|
+
const { CodexAppServerAgentProvider } = await import("../src/server/conversation/codexProvider.js");
|
|
293
|
+
assert.equal(await CodexAppServerAgentProvider.prototype.nativeThreadExists.call(inventory, "thread-3-7"), true);
|
|
294
|
+
assert.equal(requests.length, 4, "the search stops at the page that lists the thread");
|
|
295
|
+
assert.equal(await CodexAppServerAgentProvider.prototype.nativeThreadExists.call(inventory, "thread-12-0"), false);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("a refused Codex inventory entry names the check and the thread", async () => {
|
|
299
|
+
const { CodexAppServerAgentProvider } = await import("../src/server/conversation/codexProvider.js");
|
|
300
|
+
const outside = codexThreadListing([[nativeThread("thread-a"), nativeThread("thread-b", { cwd: "/elsewhere" })]]);
|
|
301
|
+
await assert.rejects(CodexAppServerAgentProvider.prototype.listNativeThreadsForCwd.call(outside.inventory, "/source"), (error) => {
|
|
302
|
+
assert.equal(error.code, "vibe64_codex_retirement_inventory_invalid");
|
|
303
|
+
assert.match(error.message, /thread thread-b .*outside the requested directory/u);
|
|
304
|
+
return true;
|
|
305
|
+
});
|
|
306
|
+
const unnamed = codexThreadListing([[{ cwd: "/source" }]]);
|
|
307
|
+
await assert.rejects(CodexAppServerAgentProvider.prototype.listNativeThreadsForCwd.call(unnamed.inventory, "/source"),
|
|
308
|
+
/listed a thread without an id/u);
|
|
309
|
+
});
|
|
@@ -9,6 +9,7 @@ import { createCodexConversationAdapter } from "../src/server/conversation/provi
|
|
|
9
9
|
import { createOpenCodeConversationAdapter } from "../src/server/conversation/providers/opencode.js";
|
|
10
10
|
import { readPersistentConversation } from "../src/server/conversation/providers/scoped.js";
|
|
11
11
|
import { createConversationTranscript, createMemoryConversationStorage } from "../src/server/conversation/index.js";
|
|
12
|
+
import { createAiConnectionResolver } from "../../connectors-catalog/src/server/ai.js";
|
|
12
13
|
|
|
13
14
|
test("scoped reads retain each request's output when an accepted steer interrupts the earlier reply", async () => {
|
|
14
15
|
const transcript = createConversationTranscript({ storage: createMemoryConversationStorage() });
|
|
@@ -707,3 +708,68 @@ test("a user Pause during the final goal read prevents automatic settings recove
|
|
|
707
708
|
assert.equal(f.requests.some(r => r.method === "thread/goal/set" && r.input.status === "active"), false);
|
|
708
709
|
f.runtime.close();
|
|
709
710
|
});
|
|
711
|
+
|
|
712
|
+
const logicalConversation = namespace => ({
|
|
713
|
+
sessionId: "session", namespace, engine: "claude",
|
|
714
|
+
read: async () => ({ engine: "claude", conversationLog: [], status: "ready" }),
|
|
715
|
+
readStream: async () => ({ messages: [] }),
|
|
716
|
+
commands: { send: async () => ({ status: "accepted" }), cancel: async () => ({ ok: true }),
|
|
717
|
+
select: async () => ({ ok: true }), inspectDelivery: async () => ({ status: "unknown" }) }
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
test("a deleted logical conversation retires its open entry without opening another", async () => {
|
|
721
|
+
const opened = [];
|
|
722
|
+
const runtime = createConversationRuntime({ authorize: () => true,
|
|
723
|
+
host: { conversation: ({ context }) => { opened.push(context.chat); return logicalConversation(`temporary-${context.chat}`); } } });
|
|
724
|
+
try {
|
|
725
|
+
const handle = await runtime.open({ id: "session", context: { chat: "one" } });
|
|
726
|
+
await handle.subscribe(() => {});
|
|
727
|
+
assert.deepEqual(await runtime.retireOpenConversation({ namespace: "temporary-one", sessionId: "session" }), { retired: true });
|
|
728
|
+
await assert.rejects(handle.read(), { code: "conversation_closed" });
|
|
729
|
+
await assert.rejects(handle.subscribe(() => {}), { code: "conversation_closed" });
|
|
730
|
+
assert.deepEqual(await runtime.retireOpenConversation({ namespace: "temporary-one", sessionId: "session" }), { retired: false },
|
|
731
|
+
"an absent entry is already retired; nothing is opened to retire it");
|
|
732
|
+
assert.deepEqual(await runtime.retireOpenConversation({ namespace: "temporary-two", sessionId: "session" }), { retired: false });
|
|
733
|
+
assert.deepEqual(opened, ["one"]);
|
|
734
|
+
const reopened = await runtime.open({ id: "session", context: { chat: "one" } });
|
|
735
|
+
assert.equal((await reopened.read()).status, "ready", "a later open starts a fresh entry");
|
|
736
|
+
} finally {
|
|
737
|
+
await runtime.close();
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test("retiring a logical conversation keeps the live stream of the conversation that shares its id", { timeout: 15_000 }, async () => {
|
|
742
|
+
const source = new TransformStream();
|
|
743
|
+
const writer = source.writable.getWriter();
|
|
744
|
+
const frame = (content, finish = false) => new TextEncoder().encode(`data: ${JSON.stringify({ choices: [{ index: 0,
|
|
745
|
+
delta: content ? { content } : {}, ...(finish ? { finish_reason: "stop" } : {}) }] })}\n\n`);
|
|
746
|
+
const streamed = Promise.withResolvers();
|
|
747
|
+
const context = { applicationId: "test-app", subjectId: "owner" };
|
|
748
|
+
const connections = createAiConnectionResolver({ authorize: actor => actor, configuration: { schemaVersion: 1, registrations: {}, integrations: {
|
|
749
|
+
assistant: { provider: "ai", accountMode: "shared", scopes: [], authentication: { method: "none" }, settings: { model: "opencode/mimo-v2.5-free" } }
|
|
750
|
+
} } });
|
|
751
|
+
const runtime = createConversationRuntime({ storage: createMemoryConversationStorage(), connections, authorize: () => true,
|
|
752
|
+
fetch: () => new Response(source.readable, { headers: { "content-type": "text/event-stream" } }),
|
|
753
|
+
host: { conversation: ({ context }) => context.chat ? logicalConversation(`temporary-${context.chat}`) : null } });
|
|
754
|
+
let finished = false;
|
|
755
|
+
try {
|
|
756
|
+
const main = await runtime.open({ id: "session", context, configuration: { systemPrompt: "Give concise answers.", integrationId: "assistant" } });
|
|
757
|
+
const chat = await runtime.open({ id: "session", context: { ...context, chat: "one" } });
|
|
758
|
+
await chat.subscribe(() => {});
|
|
759
|
+
await main.subscribe(event => { if (event.type === "message") streamed.resolve(); });
|
|
760
|
+
assert.equal((await main.send({ messageId: "main-message", text: "Hello" })).status, "accepted");
|
|
761
|
+
await writer.write(frame("Main words"));
|
|
762
|
+
await streamed.promise;
|
|
763
|
+
assert.deepEqual(await runtime.retireOpenConversation({ namespace: "temporary-one", sessionId: "session" }), { retired: true });
|
|
764
|
+
assert.deepEqual((await main.read()).streaming.messages.map(({ text }) => text), ["Main words"],
|
|
765
|
+
"a logical conversation owns no runtime stream, so retiring it leaves the same-id conversation's live reply");
|
|
766
|
+
await writer.write(frame(" and more"));
|
|
767
|
+
await writer.write(frame("", true));
|
|
768
|
+
await writer.close();
|
|
769
|
+
finished = true;
|
|
770
|
+
assert.equal((await main.wait()).conversationLog[0].assistant.text, "Main words and more");
|
|
771
|
+
} finally {
|
|
772
|
+
if (!finished) await writer.abort(new Error("The test ended before the reply.")).catch(() => {});
|
|
773
|
+
await runtime.close();
|
|
774
|
+
}
|
|
775
|
+
});
|
|
@@ -735,7 +735,7 @@ test("renewal proof cleanup preserves OpenCode failure, consumption and retry th
|
|
|
735
735
|
assert.deepEqual(calls, ["proof-id"]);
|
|
736
736
|
});
|
|
737
737
|
|
|
738
|
-
test("Claude renewal proof routes
|
|
738
|
+
test("Claude renewal proof routes release only retained work and preserve retry after shutdown", async t => {
|
|
739
739
|
const nativeContext = { sessionId: "renewal-one", key: "renewal-scope" };
|
|
740
740
|
const calls = [];
|
|
741
741
|
const failure = new Error("Terminal cleanup is unconfirmed.");
|
|
@@ -744,11 +744,7 @@ test("Claude renewal proof routes use the original close owner and preserve retr
|
|
|
744
744
|
configRoot: "/unused-native-config",
|
|
745
745
|
preparation: { context() { assert.fail("No saved native conversations exist."); } },
|
|
746
746
|
process: { create() { assert.fail("Proof release must not start a process."); } },
|
|
747
|
-
store: { readSessionConversations(
|
|
748
|
-
assert.equal(current, nativeContext);
|
|
749
|
-
calls.push("restore");
|
|
750
|
-
return { context: current, ids: [] };
|
|
751
|
-
} }
|
|
747
|
+
store: { readSessionConversations() { assert.fail("Proof release must not reopen saved conversations."); } }
|
|
752
748
|
});
|
|
753
749
|
const application = {
|
|
754
750
|
readContext(current) { assert.equal(current, nativeContext); calls.push("context"); return current; },
|
|
@@ -777,7 +773,7 @@ test("Claude renewal proof routes use the original close owner and preserve retr
|
|
|
777
773
|
ok: true, closed: 1, processExitProof: { exited: true, scopeEmpty: true }, processExitProofs: []
|
|
778
774
|
});
|
|
779
775
|
}
|
|
780
|
-
assert.deepEqual(calls, Array(3).fill(["context", "
|
|
776
|
+
assert.deepEqual(calls, Array(3).fill(["context", "terminal"]).flat());
|
|
781
777
|
assert.equal(owner.closingSessions.size, 0);
|
|
782
778
|
assert.equal(owner.entries.size, 0);
|
|
783
779
|
});
|
|
@@ -929,7 +925,7 @@ test("native activity uses the configured inventory without opening Main, includ
|
|
|
929
925
|
const acquire = () => assert.fail("The native activity owner controls receipt restoration.");
|
|
930
926
|
const inspect = (...args) => {
|
|
931
927
|
calls.push("inspect");
|
|
932
|
-
if (engine === "claude") assert.deepEqual(args, [preparedContext
|
|
928
|
+
if (engine === "claude") assert.deepEqual(args, [preparedContext]);
|
|
933
929
|
else assert.deepEqual(args, [id]);
|
|
934
930
|
return true;
|
|
935
931
|
};
|
|
@@ -107,7 +107,7 @@ test("the shared element renders delayed sends and supplies retry, edit and canc
|
|
|
107
107
|
});
|
|
108
108
|
|
|
109
109
|
|
|
110
|
-
test("chronological messages and
|
|
110
|
+
test("chronological messages and Cancel during a delivery check work on phone and desktop", {
|
|
111
111
|
skip: process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION !== "1", timeout: 90_000
|
|
112
112
|
}, async () => {
|
|
113
113
|
const vite = await startViteFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
|
|
@@ -125,7 +125,23 @@ test("chronological messages and application Cancel work on phone and desktop",
|
|
|
125
125
|
}
|
|
126
126
|
await expect(page.getByRole("button", { name: "Retry delivery check" })).toBeVisible();
|
|
127
127
|
await page.screenshot({ path: `/tmp/sas-chat-recovery-${width}.png` });
|
|
128
|
-
|
|
128
|
+
// Cancel stays enabled while Checking… runs. The message stays, shown as
|
|
129
|
+
// Cancelling…, until the server agrees, and stays when it is unreachable.
|
|
130
|
+
await page.getByRole("button", { name: "Start delivery check" }).click();
|
|
131
|
+
await expect(page.getByRole("button", { name: "Retry delivery check" })).toHaveText("Checking…");
|
|
132
|
+
await expect(page.getByRole("button", { name: "Retry delivery check" })).toBeDisabled();
|
|
133
|
+
const cancel = page.getByRole("button", { name: "Cancel", exact: true });
|
|
134
|
+
await expect(cancel).toBeEnabled();
|
|
135
|
+
await cancel.click();
|
|
136
|
+
await expect(page.getByRole("status").filter({ hasText: "Cancelling…" })).toBeVisible();
|
|
137
|
+
await expect(rows).toHaveCount(5);
|
|
138
|
+
await page.getByRole("button", { name: "Server unreachable" }).click();
|
|
139
|
+
await expect(page.getByText("Couldn't reach the server — Cancel again", { exact: true })).toBeVisible();
|
|
140
|
+
await expect(page.getByText("Discard failed attempt", { exact: true })).toBeVisible();
|
|
141
|
+
await expect(cancel).toBeEnabled();
|
|
142
|
+
await cancel.click();
|
|
143
|
+
await expect(page.getByRole("status").filter({ hasText: "Cancelling…" })).toBeVisible();
|
|
144
|
+
await page.getByRole("button", { name: "Server agrees to cancel" }).click();
|
|
129
145
|
await expect(rows).toHaveCount(4);
|
|
130
146
|
await expect(page.getByText("Discard failed attempt", { exact: true })).toHaveCount(0);
|
|
131
147
|
assert.deepEqual(errors, []);
|
|
@@ -72,6 +72,8 @@ test("cancel and edit affect failed messages and preserve a newer draft", async
|
|
|
72
72
|
assert.equal(delivery.edit("one", "New draft"), null);
|
|
73
73
|
await delivery.send({ message: "Second" }, { messageId: "two" });
|
|
74
74
|
assert.equal(delivery.cancel("two"), true);
|
|
75
|
+
assert.equal(delivery.find("two").cancelling, true, "Cancel waits for the server before removing it");
|
|
76
|
+
delivery.remove("two");
|
|
75
77
|
assert.deepEqual(delivery.state.messages, []);
|
|
76
78
|
});
|
|
77
79
|
|
|
@@ -343,7 +345,6 @@ test("unconfirmed delivery preserves its authored payload for inspection without
|
|
|
343
345
|
assert.equal(turn.optimistic.status, "uncertain");
|
|
344
346
|
assert.equal(turn.user.attachments[0].name, "Original");
|
|
345
347
|
assert.deepEqual(turn.system.delivery, { messageId: "one", error: "Connection closed.", checking: false });
|
|
346
|
-
assert.equal(delivery.cancel("one"), false);
|
|
347
348
|
assert.equal(delivery.edit("one", "New draft"), null);
|
|
348
349
|
assert.equal(delivery.resend("one"), false);
|
|
349
350
|
assert.equal(await delivery.send({ message: "Hello" }, { messageId: "one" }), false);
|
|
@@ -355,6 +356,38 @@ test("unconfirmed delivery preserves its authored payload for inspection without
|
|
|
355
356
|
assert.equal(delivery.turns()[0].system, undefined);
|
|
356
357
|
});
|
|
357
358
|
|
|
359
|
+
test("Cancel keeps a pending, uncertain or failed message, shown as cancelling, until the server agrees", async () => {
|
|
360
|
+
const delivery = createAssistantMessageDelivery();
|
|
361
|
+
await delivery.send({ message: "Refused" }, { messageId: "failed", deliver: async () => ({ ok: false, error: "Refused." }) });
|
|
362
|
+
await assert.rejects(delivery.send({ message: "Lost" }, { messageId: "uncertain", uncertainOnError: true,
|
|
363
|
+
deliver: async () => { throw new Error("Internal server error."); } }), /Internal server error/);
|
|
364
|
+
const held = Promise.withResolvers();
|
|
365
|
+
const inFlight = delivery.send({ message: "In flight" }, { messageId: "pending", deliver: () => held.promise });
|
|
366
|
+
assert.deepEqual(delivery.state.messages.map(message => message.status), ["failed", "uncertain", "pending"]);
|
|
367
|
+
for (const messageId of ["failed", "uncertain", "pending"]) assert.equal(delivery.cancel(messageId), true);
|
|
368
|
+
assert.deepEqual(delivery.turns().map(turn => [turn.optimistic.id, turn.optimistic.cancelling]),
|
|
369
|
+
[["failed", true], ["uncertain", true], ["pending", true]], "Nothing leaves on the client's own authority");
|
|
370
|
+
assert.equal(delivery.cancel("uncertain"), false, "A second press waits for the same answer");
|
|
371
|
+
assert.equal(delivery.edit("failed", ""), null, "A cancelling message is not edited");
|
|
372
|
+
assert.equal(await delivery.send({ message: "Refused" }, { messageId: "failed" }), false, "nor sent again");
|
|
373
|
+
let queuedDeliveries = 0;
|
|
374
|
+
const queued = delivery.send({ message: "Queued" }, { messageId: "queued", queue: true, deliver: async () => { queuedDeliveries += 1; } });
|
|
375
|
+
assert.equal(delivery.cancel("queued"), true);
|
|
376
|
+
held.resolve({ ok: false, error: "Late failure." });
|
|
377
|
+
await inFlight;
|
|
378
|
+
assert.equal(await queued, false);
|
|
379
|
+
assert.equal(queuedDeliveries, 0, "A message cancelled while queued is never delivered");
|
|
380
|
+
assert.equal(delivery.find("pending").cancelling, true, "A late answer leaves the cancel in progress");
|
|
381
|
+
// The server could not be reached: the message stays, with Cancel again.
|
|
382
|
+
delivery.cancelFailed("uncertain");
|
|
383
|
+
assert.deepEqual(delivery.turns().find(turn => turn.optimistic.id === "uncertain").optimistic,
|
|
384
|
+
{ error: "Internal server error.", id: "uncertain", status: "uncertain", cancelError: "Couldn't reach the server — Cancel again" });
|
|
385
|
+
assert.equal(delivery.cancel("uncertain"), true, "Cancel again");
|
|
386
|
+
// The server agreed: only then does the caller remove each message.
|
|
387
|
+
for (const messageId of ["failed", "uncertain", "pending", "queued"]) delivery.remove(messageId);
|
|
388
|
+
assert.deepEqual(delivery.turns(), []);
|
|
389
|
+
});
|
|
390
|
+
|
|
358
391
|
test("reconnect restores one uncertain request and ends its HTTP wait without dispatching again", async () => {
|
|
359
392
|
const request = Promise.withResolvers();
|
|
360
393
|
const receipts = ref([]);
|
|
@@ -32,6 +32,37 @@ test("OpenCode directory readiness uses the same authenticated directory as nati
|
|
|
32
32
|
assert.equal(requests[1].signal, stopSignal);
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
test("OpenCode clients record the directories the server has loaded and reload one directory at a time", async () => {
|
|
36
|
+
const requests = [];
|
|
37
|
+
let duringDispose = null;
|
|
38
|
+
const root = createOpenCodeServerClient({ baseUrl: "http://127.0.0.1:4096", defaultDirectory: "/work/server",
|
|
39
|
+
fetchImpl: async (url, init) => {
|
|
40
|
+
const pathname = new URL(url).pathname;
|
|
41
|
+
requests.push([init.method, pathname, init.headers["x-opencode-directory"] || ""]);
|
|
42
|
+
if (pathname === "/instance/dispose") await duringDispose?.();
|
|
43
|
+
return jsonResponse(pathname === "/path" ? { directory: "/work/selected" } : true);
|
|
44
|
+
} });
|
|
45
|
+
const selected = root.forDirectory("/work/selected/");
|
|
46
|
+
const other = root.forDirectory("/work/other");
|
|
47
|
+
await root.health();
|
|
48
|
+
assert.deepEqual(root.instanceDirectories(), [], "Global routes load no directory.");
|
|
49
|
+
await selected.prepareDirectory();
|
|
50
|
+
await root.sessionStatus("ses_one");
|
|
51
|
+
await other.prepareDirectory();
|
|
52
|
+
assert.deepEqual(root.instanceDirectories(), ["/work/selected", "/work/server", "/work/other"]);
|
|
53
|
+
await selected.disposeInstance();
|
|
54
|
+
assert.deepEqual(requests.at(-1), ["POST", "/instance/dispose", "/work/selected/"]);
|
|
55
|
+
assert.deepEqual(other.instanceDirectories(), ["/work/server", "/work/other"]);
|
|
56
|
+
// A request that reaches the directory during disposal keeps it recorded.
|
|
57
|
+
duringDispose = () => other.prepareDirectory();
|
|
58
|
+
await other.disposeInstance();
|
|
59
|
+
assert.deepEqual(root.instanceDirectories(), ["/work/server", "/work/other"]);
|
|
60
|
+
duringDispose = null;
|
|
61
|
+
await root.disposeInstance();
|
|
62
|
+
assert.deepEqual(requests.at(-1), ["POST", "/instance/dispose", ""]);
|
|
63
|
+
assert.deepEqual(root.instanceDirectories(), ["/work/other"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
35
66
|
test("OpenCode directory readiness rejects cancellation and missing native confirmation", async () => {
|
|
36
67
|
const client = createOpenCodeServerClient({ baseUrl: "http://127.0.0.1:4096",
|
|
37
68
|
fetchImpl: async () => jsonResponse({ healthy: true }) });
|