@jskit-ai/assistant-core 0.2.1 → 0.2.3
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 +41 -11
- package/src/client/conversation/messageDelivery.js +23 -6
- package/src/client/lib/assistantApi.js +5 -0
- package/src/server/conversation/claudeProcess.js +171 -53
- package/src/server/conversation/claudeTurn.js +199 -63
- 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 +272 -10
- 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 +34 -14
- package/src/server/conversation/openCodeClient.js +34 -0
- package/src/server/conversation/openCodeProcess.js +37 -5
- package/src/server/conversation/openCodeRuntime.js +257 -69
- package/src/server/conversation/providers/claudeDriver.js +7 -5
- package/src/server/conversation/providers/codexDriver.js +23 -16
- package/src/server/conversation/providers/opencodeDriver.js +17 -14
- package/src/server/conversation/providers/scoped.js +5 -2
- package/src/server/conversation/runtime.js +93 -6
- package/src/server/hosts/codexConfiguration.js +4 -0
- package/test/assistantScroll.browser.test.js +134 -18
- package/test/claudeConversationSession.test.js +26 -1
- package/test/claudeLifecycle.test.js +293 -0
- package/test/claudeProcess.test.js +364 -33
- package/test/claudeTurn.test.js +192 -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 +52 -1
- package/test/conversationNativeStorage.test.js +284 -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,441 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdir, mkdtemp, 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 { createCodexConversationCommands } from "../src/server/conversation/codexConversations.js";
|
|
7
|
+
import { createCodexHelperThreadLifecycle } from "../src/server/conversation/codexHelperThreads.js";
|
|
8
|
+
import { createCodexHelperThreadLedgerOwner } from "../src/server/conversation/codexHelperThreadLedger.js";
|
|
9
|
+
import { createCodexRuntimeLifecycle } from "../src/server/conversation/codexRuntimeLifecycle.js";
|
|
10
|
+
import { createCodexSessionCleanup } from "../src/server/conversation/codexSessionCleanup.js";
|
|
11
|
+
import { createCodexSessionReconciliation } from "../src/server/conversation/codexSessionReconciliation.js";
|
|
12
|
+
import {
|
|
13
|
+
CODEX_HELPER_THREAD_LEDGER_SCHEMA_VERSION,
|
|
14
|
+
CODEX_HELPER_THREAD_LIFECYCLES,
|
|
15
|
+
createCodexHelperThreadLedger,
|
|
16
|
+
defineCodexHelperThreadRecord,
|
|
17
|
+
helperProfile
|
|
18
|
+
} from "./fixtures/codexHelperThreadLedger.js";
|
|
19
|
+
|
|
20
|
+
// Saved Helper records are legacy native ownership: a record whose session's
|
|
21
|
+
// account was removed, or a project ledger that cannot be read, must not keep
|
|
22
|
+
// a closing session or project running, and close never reopens to stop.
|
|
23
|
+
|
|
24
|
+
function savedHelperRecord(root, overrides = {}) {
|
|
25
|
+
const now = new Date().toISOString();
|
|
26
|
+
return defineCodexHelperThreadRecord({
|
|
27
|
+
createdAt: now,
|
|
28
|
+
executionProfile: helperProfile(),
|
|
29
|
+
identity: {
|
|
30
|
+
providerId: "codex",
|
|
31
|
+
providerKeyFingerprint: `sha256:${"c".repeat(64)}`,
|
|
32
|
+
runtime: {
|
|
33
|
+
accountIdentitySignature: `sha256:${"a".repeat(64)}`,
|
|
34
|
+
authStateSignature: `v1:${"b".repeat(24)}`,
|
|
35
|
+
endpoint: `unix://${path.join(root, "codex.sock")}`,
|
|
36
|
+
executionMode: "interactive",
|
|
37
|
+
executionContextHash: "c".repeat(12),
|
|
38
|
+
provider: "codex_app_server",
|
|
39
|
+
runtimeDir: path.join(root, "codex-runtime"),
|
|
40
|
+
runtimesHash: "d".repeat(12),
|
|
41
|
+
terminalEnvHash: "e".repeat(12),
|
|
42
|
+
toolHomeSource: "",
|
|
43
|
+
transport: "unix"
|
|
44
|
+
},
|
|
45
|
+
server: { userAgent: "jskit/0.2.2 (lifecycle unit test)" },
|
|
46
|
+
transportId: "codex_app_server"
|
|
47
|
+
},
|
|
48
|
+
lifecycle: CODEX_HELPER_THREAD_LIFECYCLES.READY,
|
|
49
|
+
ownershipId: "ownership-1",
|
|
50
|
+
projectContextRoot: path.join(root, "authority"),
|
|
51
|
+
projectRuntimeRoot: root,
|
|
52
|
+
revision: 1,
|
|
53
|
+
schemaVersion: CODEX_HELPER_THREAD_LEDGER_SCHEMA_VERSION,
|
|
54
|
+
sessionId: "session-1",
|
|
55
|
+
threadId: "thread-1",
|
|
56
|
+
turnId: "",
|
|
57
|
+
updatedAt: now,
|
|
58
|
+
workdir: path.join(root, "sessions", "active", "session-1", "source"),
|
|
59
|
+
...overrides
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function withProjectLedger(t) {
|
|
64
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "jskit-codex-lifecycle-"));
|
|
65
|
+
t.after(() => rm(root, { force: true, recursive: true }));
|
|
66
|
+
const helperLifecycle = createCodexHelperThreadLifecycle({
|
|
67
|
+
providerOwner: { providers: new Map() },
|
|
68
|
+
ledgerOwner: createCodexHelperThreadLedgerOwner({
|
|
69
|
+
executionProfile: (value) => structuredClone(value),
|
|
70
|
+
errorPrefix: "vibe64_codex_helper_"
|
|
71
|
+
}),
|
|
72
|
+
applicationName: "Vibe64",
|
|
73
|
+
errorPrefix: "vibe64_"
|
|
74
|
+
});
|
|
75
|
+
return { root, helperLifecycle, ledger: createCodexHelperThreadLedger({ projectRuntimeRoot: root }) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Record B's account was removed: its provider cannot be prepared again.
|
|
79
|
+
function removedAccountRestoration(root, events, { sessionId } = {}) {
|
|
80
|
+
return {
|
|
81
|
+
projectRuntimeRoot: root,
|
|
82
|
+
context: {
|
|
83
|
+
sessionId,
|
|
84
|
+
async prepareRecord(record, ledger) {
|
|
85
|
+
events.push(`prepare:${record.threadId}`);
|
|
86
|
+
return {
|
|
87
|
+
ledger,
|
|
88
|
+
retiring: true,
|
|
89
|
+
async prepareProvider() {
|
|
90
|
+
throw Object.assign(new Error("Connect Codex - Work in AI Accounts first."), { code: "vibe64_codex_helper_ownership_blocked" });
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
async publish({ failed }) {
|
|
95
|
+
events.push(`publish:${failed.map((failure) => failure.threadId).join(",")}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
test("closing a Codex session stops its live work even when a saved Helper record cannot be restored", async (t) => {
|
|
102
|
+
const { root, helperLifecycle, ledger } = await withProjectLedger(t);
|
|
103
|
+
await mkdir(path.join(root, "codex-runtime"), { recursive: true });
|
|
104
|
+
await ledger.write(savedHelperRecord(root));
|
|
105
|
+
const events = [];
|
|
106
|
+
const cleanup = createCodexSessionCleanup({
|
|
107
|
+
namespace: (id) => `codex:${id}`,
|
|
108
|
+
runOwner: {},
|
|
109
|
+
providerSessions: { owner: {
|
|
110
|
+
async drainThreadEnvironmentTasks() {},
|
|
111
|
+
async releaseSession(sessionKey) { events.push(`release:${sessionKey}`); return { exitFailure: null }; }
|
|
112
|
+
} },
|
|
113
|
+
sessionClosures: new Map(),
|
|
114
|
+
helperLifecycle,
|
|
115
|
+
journal: { clearSessionRecoveryTimers() {} },
|
|
116
|
+
conversations: {
|
|
117
|
+
codexAppServerConversations: new Map(),
|
|
118
|
+
async closeCodexAppServerConversations(sessionId) { events.push(`conversations:${sessionId}`); }
|
|
119
|
+
},
|
|
120
|
+
debugLog() {},
|
|
121
|
+
debugError: (error) => error?.message
|
|
122
|
+
});
|
|
123
|
+
await cleanup.closeSession("session-1", {
|
|
124
|
+
sessionKey: "codex:session-1",
|
|
125
|
+
async read() {
|
|
126
|
+
return {
|
|
127
|
+
helpers: removedAccountRestoration(root, events, { sessionId: "session-1" }),
|
|
128
|
+
projectRuntimeRoot: root,
|
|
129
|
+
session: {},
|
|
130
|
+
async complete(exitFailure) { events.push(`complete:${exitFailure}`); }
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
assert.deepEqual(events, [
|
|
135
|
+
"conversations:session-1",
|
|
136
|
+
"prepare:thread-1",
|
|
137
|
+
"publish:thread-1",
|
|
138
|
+
"release:codex:session-1",
|
|
139
|
+
"complete:null"
|
|
140
|
+
]);
|
|
141
|
+
assert.equal((await ledger.readAll()).records.length, 1, "the unrestorable record stays for a later connection");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("closing a Codex project stops providers past an unrestorable record and reports it by identity", async (t) => {
|
|
145
|
+
const { root, helperLifecycle, ledger } = await withProjectLedger(t);
|
|
146
|
+
await mkdir(path.join(root, "codex-runtime"), { recursive: true });
|
|
147
|
+
await ledger.write(savedHelperRecord(root));
|
|
148
|
+
await ledger.write(savedHelperRecord(root, { ownershipId: "ownership-2", sessionId: "session-2", threadId: "thread-2",
|
|
149
|
+
identity: { ...savedHelperRecord(root).identity, runtime: { ...savedHelperRecord(root).identity.runtime,
|
|
150
|
+
runtimeDir: path.join(root, "removed-runtime") } },
|
|
151
|
+
workdir: path.join(root, "sessions", "active", "session-2", "source") }));
|
|
152
|
+
const events = [];
|
|
153
|
+
const cleanup = createCodexSessionCleanup({
|
|
154
|
+
namespace: (id) => `codex:${id}`,
|
|
155
|
+
runOwner: {},
|
|
156
|
+
providerSessions: { managed: new Map(), keyFields: () => ({}), owner: {
|
|
157
|
+
async stopProvidersForProject(input) {
|
|
158
|
+
events.push(`stop:${input.projectContextRoot}`);
|
|
159
|
+
return { failed: [], ok: true, projectContextRoot: input.projectContextRoot, providerCount: 1, results: [], stopped: 1 };
|
|
160
|
+
}
|
|
161
|
+
} },
|
|
162
|
+
sessionClosures: new Map(),
|
|
163
|
+
helperLifecycle,
|
|
164
|
+
journal: { clearSessionRecoveryTimers() {} },
|
|
165
|
+
conversations: {
|
|
166
|
+
codexAppServerConversations: new Map([["codex:session-1", new Map()]]),
|
|
167
|
+
async closeCodexAppServerConversations(sessionId) { events.push(`conversations:${sessionId}`); }
|
|
168
|
+
},
|
|
169
|
+
debugLog() {},
|
|
170
|
+
debugError: (error) => error?.message
|
|
171
|
+
});
|
|
172
|
+
const result = await cleanup.closeProject({ projectContextRoot: "/project" }, {
|
|
173
|
+
helpers: Promise.resolve(removedAccountRestoration(root, events))
|
|
174
|
+
});
|
|
175
|
+
assert.deepEqual(events.filter((event) => !event.startsWith("prepare:")),
|
|
176
|
+
["conversations:session-1", "publish:thread-1", "stop:/project"]);
|
|
177
|
+
assert.deepEqual(events.filter((event) => event.startsWith("prepare:")).sort(), ["prepare:thread-1", "prepare:thread-2"]);
|
|
178
|
+
assert.equal(result.ok, true, "the stop itself succeeded");
|
|
179
|
+
assert.deepEqual(result.helperRestore.failed.map(({ sessionId, threadId }) => ({ sessionId, threadId })),
|
|
180
|
+
[{ sessionId: "session-1", threadId: "thread-1" }]);
|
|
181
|
+
assert.deepEqual((await ledger.readAll()).records.map((record) => record.threadId), ["thread-1"],
|
|
182
|
+
"the record whose runtime is gone is retired; the other stays for a later connection");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("Helper restoration reports an unreadable ledger and a refused notice instead of rejecting", async (t) => {
|
|
186
|
+
const { root, helperLifecycle, ledger } = await withProjectLedger(t);
|
|
187
|
+
await writeFile(ledger.root, "not a directory");
|
|
188
|
+
const unreadable = await helperLifecycle.restoreThreads(root, {
|
|
189
|
+
async prepareRecord() { assert.fail("an unreadable ledger lists no record"); },
|
|
190
|
+
async publish() {}
|
|
191
|
+
});
|
|
192
|
+
assert.equal(unreadable.ok, false);
|
|
193
|
+
assert.equal(unreadable.failed.length, 1);
|
|
194
|
+
assert.equal(unreadable.failed[0].projectRuntimeRoot, root);
|
|
195
|
+
await rm(ledger.root, { force: true });
|
|
196
|
+
|
|
197
|
+
await mkdir(path.join(root, "codex-runtime"), { recursive: true });
|
|
198
|
+
await ledger.write(savedHelperRecord(root, { identity: { ...savedHelperRecord(root).identity,
|
|
199
|
+
runtime: { ...savedHelperRecord(root).identity.runtime, runtimeDir: path.join(root, "removed-runtime") } } }));
|
|
200
|
+
const refused = await helperLifecycle.restoreThreads(root, {
|
|
201
|
+
async prepareRecord(record, recordLedger) { return { ledger: recordLedger, retiring: true }; },
|
|
202
|
+
async publish() {
|
|
203
|
+
throw Object.assign(new Error("Vibe64 session is already archived: session-1"), { code: "vibe64_session_archived" });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
assert.equal(refused.ok, false);
|
|
207
|
+
assert.deepEqual(refused.retiredThreadIds, ["thread-1"], "restoration finished before the notice was refused");
|
|
208
|
+
assert.deepEqual(refused.failed.map((failure) => failure.code), ["vibe64_session_archived"]);
|
|
209
|
+
assert.throws(() => helperLifecycle.assertRestored(refused), /already archived/u);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("an assertion over failed restoration names the failing session, thread and cause", async (t) => {
|
|
213
|
+
const { helperLifecycle } = await withProjectLedger(t);
|
|
214
|
+
assert.throws(() => helperLifecycle.assertRestored({
|
|
215
|
+
ok: false,
|
|
216
|
+
projectRuntimeRoot: "/runtime",
|
|
217
|
+
failed: [
|
|
218
|
+
{ code: "vibe64_codex_helper_ownership_blocked", error: "Connect Codex - Work in AI Accounts first.", sessionId: "session-1", threadId: "thread-1" },
|
|
219
|
+
{ code: "vibe64_codex_helper_ledger_malformed", error: "Record exceeds 65536 bytes.", sessionId: "", threadId: "" }
|
|
220
|
+
]
|
|
221
|
+
}), (error) => {
|
|
222
|
+
assert.match(error.message, /session session-1, thread thread-1: Connect Codex - Work in AI Accounts first\./u);
|
|
223
|
+
assert.match(error.message, /1 more/u);
|
|
224
|
+
assert.equal(error.details.failed.length, 2);
|
|
225
|
+
return true;
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
function conversationCommands({ context = async () => ({ result: { ok: false, code: "vibe64_session_closing", error: "The session is closing." } }),
|
|
230
|
+
scope = context,
|
|
231
|
+
ensureSessionProvider = async () => assert.fail("closing or deleting must not open a Codex provider"),
|
|
232
|
+
providerSessions = {} } = {}) {
|
|
233
|
+
return createCodexConversationCommands({
|
|
234
|
+
checkpoint: async () => {},
|
|
235
|
+
conversationPreparation: {
|
|
236
|
+
context,
|
|
237
|
+
scope,
|
|
238
|
+
control(_sessionId, input) {
|
|
239
|
+
return { admission: { release() {} }, threadId: String(input.threadId || ""), turnId: "", unavailable: () => null };
|
|
240
|
+
},
|
|
241
|
+
failure(error) { return { ok: false, error: error.message }; },
|
|
242
|
+
isolation: { assertCompatibility() {} }
|
|
243
|
+
},
|
|
244
|
+
errorPrefix: "vibe64_",
|
|
245
|
+
helperLifecycle: { inspectControl: () => undefined },
|
|
246
|
+
namespace: (sessionId) => `codex:${String(sessionId || "").trim()}`,
|
|
247
|
+
providerSessions,
|
|
248
|
+
sessionConnection: (sessionId) => ({ providerKey: `codex-helper:${sessionId}`, runtimeHost: { stop: "prepared" } }),
|
|
249
|
+
ensureSessionProvider,
|
|
250
|
+
turnOutcomes: { USER_CANCELLED: "cancelled" }
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function observingProvider(name, calls, { failStop = false } = {}) {
|
|
255
|
+
return {
|
|
256
|
+
observationFailure: null,
|
|
257
|
+
isAvailable: () => true,
|
|
258
|
+
subscribe() { return () => calls.push(`unsubscribe:${name}`); },
|
|
259
|
+
async readThreadStatus(threadId) { calls.push(`read:${name}:${threadId}`); return { status: { type: "active" } }; },
|
|
260
|
+
async stopThreadForObservationLoss(threadId) {
|
|
261
|
+
calls.push(`stop:${name}:${threadId}`);
|
|
262
|
+
if (failStop) throw new Error("Codex did not confirm that its turn stopped.");
|
|
263
|
+
return { goal: null };
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
test("closing a session stops each Temporary chat through the provider that observes it", async () => {
|
|
269
|
+
const calls = [];
|
|
270
|
+
const providers = new Map([
|
|
271
|
+
["main", observingProvider("shared", calls)],
|
|
272
|
+
["main-2", observingProvider("shared", calls, { failStop: true })],
|
|
273
|
+
["main-3", observingProvider("shared", calls)]
|
|
274
|
+
]);
|
|
275
|
+
const closedByOwner = observingProvider("closed", calls);
|
|
276
|
+
// The provider owner's own surface: lookups and [key, provider] iteration, no values().
|
|
277
|
+
const ownerProviders = { get: (key) => providers.get(key), keys: () => providers.keys(),
|
|
278
|
+
*entries() { yield* providers.entries(); }, [Symbol.iterator]() { return this.entries(); } };
|
|
279
|
+
const commands = conversationCommands({ providerSessions: { owner: { providers: ownerProviders } } });
|
|
280
|
+
const first = commands.observeCodexConversation("session-1", "chat-1", providers.get("main"));
|
|
281
|
+
const second = commands.observeCodexConversation("session-1", "chat-2", providers.get("main-2"));
|
|
282
|
+
const third = commands.observeCodexConversation("session-1", "chat-3", providers.get("main-3"));
|
|
283
|
+
const detached = commands.observeCodexConversation("session-1", "chat-4", closedByOwner);
|
|
284
|
+
for (const state of [first, second, third, detached]) state.status = "inProgress";
|
|
285
|
+
|
|
286
|
+
await assert.rejects(commands.closeCodexAppServerConversations("session-1"), (error) => {
|
|
287
|
+
assert.equal(error.code, "vibe64_codex_conversation_close_failed");
|
|
288
|
+
assert.deepEqual(error.details.failed.map((failure) => failure.conversationId), ["chat-2"]);
|
|
289
|
+
assert.match(error.message, /chat-2/u);
|
|
290
|
+
return true;
|
|
291
|
+
});
|
|
292
|
+
assert.deepEqual(calls.filter((call) => call.startsWith("stop:")), [
|
|
293
|
+
"stop:shared:chat-1", "stop:shared:chat-2", "stop:shared:chat-3"
|
|
294
|
+
], "one conversation's failure does not skip the others");
|
|
295
|
+
assert.equal(first.status, "interrupted");
|
|
296
|
+
assert.equal(third.status, "interrupted");
|
|
297
|
+
assert.equal(commands.codexAppServerConversation("session-1", "chat-1"), null);
|
|
298
|
+
assert.equal(commands.codexAppServerConversation("session-1", "chat-2"), second, "the unconfirmed one stays for a retry");
|
|
299
|
+
assert.equal(commands.codexAppServerConversation("session-1", "chat-3"), null);
|
|
300
|
+
assert.equal(commands.codexAppServerConversation("session-1", "chat-4"), null,
|
|
301
|
+
"a provider its owner closed is not reconnected to be stopped");
|
|
302
|
+
assert.equal(calls.some((call) => call.includes(":closed:")), false);
|
|
303
|
+
await commands.closeCodexAppServerConversations("session-without-conversations");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("deleting an expired ephemeral helper receipt starts no Codex app-server and still requires verified exit", async () => {
|
|
307
|
+
const calls = [];
|
|
308
|
+
let failRetainedStop = false;
|
|
309
|
+
const sessionKey = "codex:explanation_job";
|
|
310
|
+
// A runtime whose earlier stop could not prove exit, one another provider shares, and another session's.
|
|
311
|
+
const retained = { provider: { name: "retained" }, providerKey: "codex-helper:retained",
|
|
312
|
+
providerOptions: { runtimeDir: "/runtime/retained" }, sessionKey };
|
|
313
|
+
const shared = { provider: { name: "shared" }, providerKey: "codex-helper:shared", providerOptions: {}, sessionKey };
|
|
314
|
+
const elsewhere = { provider: { name: "elsewhere" }, providerKey: "codex-helper:other", providerOptions: {}, sessionKey: "codex:other" };
|
|
315
|
+
const commands = conversationCommands({
|
|
316
|
+
context: async () => assert.fail("an expired helper has no native thread to delete"),
|
|
317
|
+
scope: async () => assert.fail("deleting needs no account, tool home or workdir"),
|
|
318
|
+
providerSessions: { owner: {
|
|
319
|
+
providers: new Map(),
|
|
320
|
+
providerOwners: { get: () => undefined },
|
|
321
|
+
ownedRuntimes: { *values() { yield retained; yield shared; yield elsewhere; } },
|
|
322
|
+
async stopCachedProvidersForSession(key, options) {
|
|
323
|
+
calls.push(`live:${key}:${options.requireStopped}`);
|
|
324
|
+
return { failed: [], ok: true, providerCount: 0, results: [], stopped: 0 };
|
|
325
|
+
},
|
|
326
|
+
runtimeIsShared: (providerKey) => providerKey === shared.providerKey,
|
|
327
|
+
withLifecycle(operation, providerKey, providerOptions) {
|
|
328
|
+
// The owner locks a runtime by its options' runtime directory when it has them.
|
|
329
|
+
calls.push(`lock:${providerKey}:${providerOptions?.runtimeDir || "provider"}`);
|
|
330
|
+
return operation();
|
|
331
|
+
},
|
|
332
|
+
async stopOwnedRuntime(record) {
|
|
333
|
+
calls.push(`retained:${record.provider.name}`);
|
|
334
|
+
if (failRetainedStop) {
|
|
335
|
+
throw Object.assign(new Error("Codex app-server process exit could not be verified."),
|
|
336
|
+
{ code: "codex_runtime_exit_unverified", retryable: true });
|
|
337
|
+
}
|
|
338
|
+
return { providerKey: record.providerKey, stopped: true };
|
|
339
|
+
}
|
|
340
|
+
} }
|
|
341
|
+
});
|
|
342
|
+
const scope = { id: "explanation_job", workdir: "/source-that-was-removed" };
|
|
343
|
+
const input = { ephemeral: true, executionProfile: helperProfile(), conversationId: "helper-thread" };
|
|
344
|
+
failRetainedStop = true;
|
|
345
|
+
await assert.rejects(commands.deleteScopedCodexAppServerConversation(scope.id, input, { assistantScope: scope }), (error) => {
|
|
346
|
+
assert.equal(error.code, "codex_runtime_exit_unverified");
|
|
347
|
+
assert.equal(error.retryable, true);
|
|
348
|
+
return true;
|
|
349
|
+
});
|
|
350
|
+
failRetainedStop = false;
|
|
351
|
+
calls.length = 0;
|
|
352
|
+
const result = await commands.deleteScopedCodexAppServerConversation(scope.id, input, { assistantScope: scope });
|
|
353
|
+
assert.equal(result.ok, true, JSON.stringify(result));
|
|
354
|
+
assert.equal(result.conversationExpired, true);
|
|
355
|
+
assert.equal(result.providerExit.stopped, true);
|
|
356
|
+
assert.deepEqual(calls, [`live:${sessionKey}:true`, "lock:codex-helper:retained:/runtime/retained", "retained:retained"],
|
|
357
|
+
"only this session's unshared retained runtime is stopped, under its own runtime lock, and nothing is started");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("an account-change invalidation stops runtimes without reopening saved Helper threads", async () => {
|
|
361
|
+
const calls = [];
|
|
362
|
+
const lifecycle = createCodexRuntimeLifecycle({
|
|
363
|
+
pendingWork: {},
|
|
364
|
+
observer: { eventSubscriptions: new Map() },
|
|
365
|
+
checkManagedConnection: async () => {},
|
|
366
|
+
providerSessions: { managed: new Map(), assertOpen() {}, owner: {
|
|
367
|
+
beginShutdown() {},
|
|
368
|
+
async invalidateRuntimes(input) { calls.push(`invalidate:${input.reason}`); return { failed: [], providerCount: 1, results: [], stopped: 1 }; }
|
|
369
|
+
} },
|
|
370
|
+
helperLifecycle: {
|
|
371
|
+
async restoreThreads() { calls.push("restore"); return { ok: false, failed: [{ error: "stale" }] }; },
|
|
372
|
+
assertRestored() { throw new Error("a stale saved record aborted the invalidation"); }
|
|
373
|
+
},
|
|
374
|
+
captureContext: () => null,
|
|
375
|
+
runInContext: (_context, operation) => operation(),
|
|
376
|
+
debugLog() {},
|
|
377
|
+
debugError: (error) => error?.message
|
|
378
|
+
});
|
|
379
|
+
const result = await lifecycle.invalidateRuntimes({ reason: "account-change" }, {
|
|
380
|
+
enabled: true,
|
|
381
|
+
get helpers() { calls.push("helpers"); return Promise.resolve({ projectRuntimeRoot: "/runtime", context: {} }); }
|
|
382
|
+
});
|
|
383
|
+
assert.equal(result.stopped, 1);
|
|
384
|
+
assert.deepEqual(calls, ["invalidate:account-change"]);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("reconciliation keeps every session and prune retires past one failed provider", async (t) => {
|
|
388
|
+
const { root, helperLifecycle, ledger } = await withProjectLedger(t);
|
|
389
|
+
await ledger.write(savedHelperRecord(root, { identity: { ...savedHelperRecord(root).identity,
|
|
390
|
+
runtime: { ...savedHelperRecord(root).identity.runtime, runtimeDir: path.join(root, "removed-runtime") } } }));
|
|
391
|
+
const retired = [];
|
|
392
|
+
const managed = new Map([
|
|
393
|
+
["provider-a", { sessionId: "stale-a", projectContext: { targetRoot: "/project" } }],
|
|
394
|
+
["provider-b", { sessionId: "stale-b", projectContext: { targetRoot: "/project" } }]
|
|
395
|
+
]);
|
|
396
|
+
let generation = 0;
|
|
397
|
+
const reconciliation = createCodexSessionReconciliation({
|
|
398
|
+
readiness: {
|
|
399
|
+
async reconcileThread(sessionId) { return { ok: true, sessionId, status: "reconciled" }; },
|
|
400
|
+
async waitForThreadReconciliations() {}
|
|
401
|
+
},
|
|
402
|
+
recoverSessionObservationLoss: async () => null,
|
|
403
|
+
providerSessions: { assertOpen() {}, managed, owner: { providers: new Map() } },
|
|
404
|
+
helperLifecycle,
|
|
405
|
+
lifecycle: {
|
|
406
|
+
nextReconcileGeneration: () => ++generation,
|
|
407
|
+
get reconcileGeneration() { return generation; },
|
|
408
|
+
reconcileTasks: new Set(),
|
|
409
|
+
async retireProvider(providerKey) {
|
|
410
|
+
retired.push(providerKey);
|
|
411
|
+
if (providerKey === "provider-a") throw Object.assign(new Error("Codex did not confirm deletion."), { retryable: true });
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
createRuntime: async () => ({ projectContextRoot: "/project", stateRoot: root, async getSession(id) { return { sessionId: id }; } }),
|
|
415
|
+
namespace: (id) => `codex:${id}`,
|
|
416
|
+
turnState: () => ({ status: "idle" }),
|
|
417
|
+
sessionIdRequiredError: "A session is required.",
|
|
418
|
+
debugLog() {},
|
|
419
|
+
debugError: (error) => error?.message
|
|
420
|
+
});
|
|
421
|
+
const result = await reconciliation.reconcileSessions(["good-a", "good-b"], {}, {
|
|
422
|
+
enabled: true,
|
|
423
|
+
runtimeBusyCode: "vibe64_codex_runtime_busy",
|
|
424
|
+
failureMessage: "Codex reconciliation failed.",
|
|
425
|
+
helperRestoration: () => ({ projectRuntimeRoot: root, context: {
|
|
426
|
+
async prepareRecord(record, recordLedger) { return { ledger: recordLedger, retiring: true }; },
|
|
427
|
+
async publish() {
|
|
428
|
+
throw Object.assign(new Error("Vibe64 session is read-only while its renewal is in progress: renewing"),
|
|
429
|
+
{ code: "vibe64_session_renewal_quiesced" });
|
|
430
|
+
}
|
|
431
|
+
} }),
|
|
432
|
+
helperInventory: () => assert.fail("inventory requires a fully restored ledger"),
|
|
433
|
+
async session(sessionId) { return { context: { session: { sessionId } }, thread: {} }; }
|
|
434
|
+
});
|
|
435
|
+
assert.deepEqual(result.results.map((entry) => entry.sessionId), ["good-a", "good-b"]);
|
|
436
|
+
assert.deepEqual(retired, ["provider-a", "provider-b"]);
|
|
437
|
+
assert.deepEqual(result.failed.map((failure) => failure.code || failure.providerKey), [
|
|
438
|
+
"vibe64_session_renewal_quiesced", "provider-a"
|
|
439
|
+
]);
|
|
440
|
+
assert.equal(result.ok, false);
|
|
441
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
@@ -14,7 +14,8 @@ async function fixture(t, source) {
|
|
|
14
14
|
const command = path.join(runtimeDir, "codex");
|
|
15
15
|
await writeFile(command, `#!${process.execPath}\n${source}\n`);
|
|
16
16
|
await chmod(command, 0o700);
|
|
17
|
-
|
|
17
|
+
// Catalogue reuse reads the Codex home's file metadata; never the person's own.
|
|
18
|
+
return { runtimeDir, command, codexHome: runtimeDir };
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
for (const [name, source] of [
|
|
@@ -46,6 +47,51 @@ test("stopping startup cancels the exporter and publishes no catalogue", async (
|
|
|
46
47
|
await assert.rejects(stat(path.join(f.runtimeDir, "models.json")), { code: "ENOENT" });
|
|
47
48
|
});
|
|
48
49
|
|
|
50
|
+
test("startup exports the catalogue again only when the Codex binary, account, configuration or added models change", async (t) => {
|
|
51
|
+
const exporter = source => `
|
|
52
|
+
const fs = require("node:fs");
|
|
53
|
+
fs.appendFileSync(require("node:path").join(__dirname, "exports.log"), "${source}\\n");
|
|
54
|
+
process.stdout.write(JSON.stringify({ models: [{ slug: "gpt-native", priority: 1 }] }));
|
|
55
|
+
`;
|
|
56
|
+
const f = await fixture(t, exporter("first"));
|
|
57
|
+
const codexHome = path.join(f.runtimeDir, "home");
|
|
58
|
+
await mkdir(codexHome);
|
|
59
|
+
const options = { ...f, codexHome, additionalModels: [{ slug: "glm-5" }] };
|
|
60
|
+
const exports = async () => (await readFile(path.join(f.runtimeDir, "exports.log"), "utf8")).trim().split("\n");
|
|
61
|
+
const catalogPath = await prepareCodexModelCatalog(options);
|
|
62
|
+
const catalogue = await readFile(catalogPath, "utf8");
|
|
63
|
+
// Reconnects and restarts with nothing changed reuse the exported catalogue.
|
|
64
|
+
assert.equal(await prepareCodexModelCatalog(options), catalogPath);
|
|
65
|
+
assert.equal(await prepareCodexModelCatalog(options), catalogPath);
|
|
66
|
+
assert.equal(await readFile(catalogPath, "utf8"), catalogue);
|
|
67
|
+
assert.deepEqual(await exports(), ["first"]);
|
|
68
|
+
|
|
69
|
+
const changes = [
|
|
70
|
+
["signing in", () => writeFile(path.join(codexHome, "auth.json"), "{\"tokens\":{}}")],
|
|
71
|
+
["changing the account", () => writeFile(path.join(codexHome, "auth.json"), "{\"tokens\":{\"account_id\":\"other\"}}")],
|
|
72
|
+
["changing configuration", () => writeFile(path.join(codexHome, "config.toml"), "model = \"gpt-native\"\n")],
|
|
73
|
+
["updating Codex", async () => {
|
|
74
|
+
await rm(f.command);
|
|
75
|
+
await writeFile(f.command, `#!${process.execPath}\n${exporter("updated")}\n`);
|
|
76
|
+
await chmod(f.command, 0o700);
|
|
77
|
+
}],
|
|
78
|
+
["adding a model", () => { options.additionalModels = [...options.additionalModels, { slug: "kimi-k3" }]; }],
|
|
79
|
+
["switching to the bundled catalogue", () => { options.bundled = true; }],
|
|
80
|
+
["losing the exported file", () => rm(catalogPath)]
|
|
81
|
+
];
|
|
82
|
+
let expected = 1;
|
|
83
|
+
for (const [change, apply] of changes) {
|
|
84
|
+
await apply();
|
|
85
|
+
await prepareCodexModelCatalog(options);
|
|
86
|
+
await prepareCodexModelCatalog(options);
|
|
87
|
+
expected += 1;
|
|
88
|
+
assert.equal((await exports()).length, expected, `${change} must export once more`);
|
|
89
|
+
}
|
|
90
|
+
assert.deepEqual(JSON.parse(await readFile(catalogPath, "utf8")).models.map(model => model.slug),
|
|
91
|
+
["gpt-native", "glm-5", "kimi-k3"]);
|
|
92
|
+
assert.equal((await stat(catalogPath)).mode & 0o777, 0o600);
|
|
93
|
+
});
|
|
94
|
+
|
|
49
95
|
function liveCatalogFixture() {
|
|
50
96
|
const calls = [];
|
|
51
97
|
const captures = { connectionGeneration: 1, failModelLists: 0 };
|
|
@@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { test } from "node:test";
|
|
6
|
+
import { assertCodexAuthPreflightReady } from "../src/server/conversation/codexAuthPreflight.js";
|
|
6
7
|
import { createCodexAppServerRuntime } from "../src/server/conversation/codexProcess.js";
|
|
7
8
|
|
|
8
9
|
/** Starts a runtime whose execution host records the launch and reports failure. */
|
|
@@ -42,6 +43,17 @@ test("an empty umask inherits the host's own and emits no umask line", async (t)
|
|
|
42
43
|
assert.match((await launchRequest(t, { umask: "0022" })).args[1], /^umask 0022\n/);
|
|
43
44
|
});
|
|
44
45
|
|
|
46
|
+
test("main and helper app-servers start with plugin and app background work off", async (t) => {
|
|
47
|
+
for (const executionMode of ["interactive", "helper"]) {
|
|
48
|
+
const request = await launchRequest(t, {}, executionMode);
|
|
49
|
+
const codexArgs = request.args.slice(request.args.indexOf("app-server"));
|
|
50
|
+
for (const feature of ["plugins", "apps"]) {
|
|
51
|
+
const index = codexArgs.indexOf(`features.${feature}=false`);
|
|
52
|
+
assert.ok(index > 0 && codexArgs[index - 1] === "-c", `${executionMode} app-server disables ${feature}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
45
57
|
test("a non-octal umask is refused before it reaches the startup script", () => {
|
|
46
58
|
assert.throws(() => createCodexAppServerRuntime({
|
|
47
59
|
configuration: { umask: "0007; touch /tmp/x" },
|
|
@@ -49,3 +61,12 @@ test("a non-octal umask is refused before it reaches the startup script", () =>
|
|
|
49
61
|
execution: {}
|
|
50
62
|
}), /umask must be octal/);
|
|
51
63
|
});
|
|
64
|
+
|
|
65
|
+
test("the Codex sign-in check names itself to the execution host", async () => {
|
|
66
|
+
let request;
|
|
67
|
+
const execution = { async run(value) { request = value; return { ok: true, exitCode: 0, output: "{}" }; } };
|
|
68
|
+
await assertCodexAuthPreflightReady({ codexCommand: process.execPath }, { execution });
|
|
69
|
+
assert.equal(request.mode, "capture");
|
|
70
|
+
assert.deepEqual(request.args.slice(-2), ["debug", "models"]);
|
|
71
|
+
assert.deepEqual(request.execution, { label: "Checking the Codex sign-in", operationId: "codex-auth-preflight" });
|
|
72
|
+
});
|