@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.
Files changed (61) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
  3. package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
  4. package/locales/en.json +4 -2
  5. package/package.json +5 -5
  6. package/src/client/conversation/AssistantConversationElement.vue +8 -1
  7. package/src/client/conversation/AssistantGoalControl.vue +2 -2
  8. package/src/client/conversation/AssistantTranscript.vue +41 -11
  9. package/src/client/conversation/messageDelivery.js +23 -6
  10. package/src/client/lib/assistantApi.js +5 -0
  11. package/src/server/conversation/claudeProcess.js +171 -53
  12. package/src/server/conversation/claudeTurn.js +199 -63
  13. package/src/server/conversation/codexAuthPreflight.js +3 -0
  14. package/src/server/conversation/codexConfiguration.js +42 -4
  15. package/src/server/conversation/codexConversations.js +256 -114
  16. package/src/server/conversation/codexEvents.js +16 -3
  17. package/src/server/conversation/codexHelperThreadRecovery.js +17 -4
  18. package/src/server/conversation/codexHelperThreads.js +10 -1
  19. package/src/server/conversation/codexHomeStorage.js +69 -0
  20. package/src/server/conversation/codexProcess.js +7 -2
  21. package/src/server/conversation/codexProvider.js +57 -46
  22. package/src/server/conversation/codexProviderOwner.js +272 -10
  23. package/src/server/conversation/codexProviderSelection.js +13 -3
  24. package/src/server/conversation/codexRuntimeLifecycle.js +56 -4
  25. package/src/server/conversation/codexSessionCleanup.js +67 -20
  26. package/src/server/conversation/codexSessionReconciliation.js +18 -4
  27. package/src/server/conversation/codexTurn.js +9 -2
  28. package/src/server/conversation/continuity.js +34 -14
  29. package/src/server/conversation/openCodeClient.js +34 -0
  30. package/src/server/conversation/openCodeProcess.js +37 -5
  31. package/src/server/conversation/openCodeRuntime.js +257 -69
  32. package/src/server/conversation/providers/claudeDriver.js +7 -5
  33. package/src/server/conversation/providers/codexDriver.js +23 -16
  34. package/src/server/conversation/providers/opencodeDriver.js +17 -14
  35. package/src/server/conversation/providers/scoped.js +5 -2
  36. package/src/server/conversation/runtime.js +93 -6
  37. package/src/server/hosts/codexConfiguration.js +4 -0
  38. package/test/assistantScroll.browser.test.js +134 -18
  39. package/test/claudeConversationSession.test.js +26 -1
  40. package/test/claudeLifecycle.test.js +293 -0
  41. package/test/claudeProcess.test.js +364 -33
  42. package/test/claudeTurn.test.js +192 -3
  43. package/test/codexAccountChange.test.js +261 -0
  44. package/test/codexConversationsCheckpoint.test.js +3 -0
  45. package/test/codexEvents.test.js +23 -1
  46. package/test/codexHomeStorage.test.js +57 -0
  47. package/test/codexInteractiveArguments.test.js +9 -0
  48. package/test/codexLifecycleIsolation.test.js +441 -0
  49. package/test/codexModelCatalog.test.js +48 -2
  50. package/test/codexProcess.test.js +21 -0
  51. package/test/codexTemporaryProviderRelease.test.js +180 -0
  52. package/test/codexTurn.test.js +52 -1
  53. package/test/conversationNativeStorage.test.js +284 -0
  54. package/test/conversationRuntime.test.js +66 -0
  55. package/test/conversationSession.test.js +4 -8
  56. package/test/messageDelivery.browser.test.js +18 -2
  57. package/test/messageDelivery.test.js +34 -1
  58. package/test/openCodeClient.test.js +31 -0
  59. package/test/openCodeConnectionChange.test.js +166 -0
  60. package/test/openCodeProcess.test.js +36 -0
  61. package/test/openCodeRuntime.test.js +25 -78
@@ -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 application Cancel work on phone and desktop", {
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
- await page.getByRole("button", { name: "Cancel", exact: true }).click();
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 }) });
@@ -0,0 +1,166 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { createOpenCodeSharedRuntime, openCodeServerForDirectory } from "../src/server/conversation/openCodeRuntime.js";
5
+
6
+ // One shared server whose native directories record what OpenCode is asked to do.
7
+ function sharedServer() {
8
+ const calls = [], rows = new Map(), loaded = new Set();
9
+ const client = (directory = "/work/server") => ({
10
+ async health() { return { healthy: true }; },
11
+ async authenticateApiKey(providerId, key) { calls.push(["key", providerId, key]); },
12
+ async removeAuthentication(providerId) { calls.push(["remove", providerId]); },
13
+ async disposeInstance() { calls.push(["reload", directory]); loaded.delete(directory); },
14
+ instanceDirectories: () => [...loaded],
15
+ forDirectory(next) { loaded.add(next); return client(next); },
16
+ async prompt(conversationId, input) {
17
+ calls.push(["prompt", directory, conversationId]);
18
+ rows.set(conversationId, [{ id: input.id, type: "user", time: { created: 1 } },
19
+ { id: `${input.id}:answer`, type: "assistant", text: "Done.", time: { created: 2, completed: 3 } }]);
20
+ return { id: input.id };
21
+ },
22
+ async messages(conversationId) { return rows.get(conversationId) || []; },
23
+ async sessionStatus() { return { type: "idle" }; },
24
+ async interrupt() { return true; }
25
+ });
26
+ const stops = [];
27
+ const server = { client: client(), async stop() { stops.push(server); return { exited: true }; } };
28
+ return { server, calls, rows, stops };
29
+ }
30
+
31
+ const deepseek = (fingerprint, apiKey) => ({ modelProviderId: "deepseek", canonicalUrl: "", endpointCode: "native", fingerprint, apiKey });
32
+
33
+ async function startedRuntime(saved) {
34
+ const runtime = createOpenCodeSharedRuntime();
35
+ const native = sharedServer();
36
+ const shared = await runtime.ensure(saved[0], async () => ({ server: native.server, connections: [...saved] }), async () => saved);
37
+ return { runtime, native, shared };
38
+ }
39
+
40
+ // A main-conversation turn in flight until its answer arrives.
41
+ function runningTurn(t, runtime, shared, workdir, { failure = "", completeResult = async (_turn, { failure: value }) => value } = {}) {
42
+ const target = { key: workdir, workdir, upstreamSessionId: `${workdir}:thread`, abortController: new AbortController(),
43
+ server: openCodeServerForDirectory(shared.server, workdir) };
44
+ const turn = { id: "input", inputMessageId: "input", threadId: target.upstreamSessionId, active: true, state: "active",
45
+ eventStartedAt: 1, abortController: new AbortController() };
46
+ let observedFailure = null;
47
+ const monitor = runtime.beginMonitor(workdir, target, turn, {
48
+ observe: () => ({ readFailure: () => observedFailure, close: async () => {} }),
49
+ finalResponse: { agent: "agent", model: { providerID: "deepseek", id: "model" }, recoveryMessageId: "recovery", readError: () => "" },
50
+ projectMessages: async () => ({ failure: "", providerApiFailure: false, finalAssistantResult: null }),
51
+ writeRun: async () => {},
52
+ completeResult,
53
+ onRetired() {}
54
+ });
55
+ t.after(async () => { turn.abortController.abort(); await monitor.catch(() => null); });
56
+ return {
57
+ target, turn, monitor,
58
+ answer(native) {
59
+ native.rows.set(target.upstreamSessionId, [{ id: "input", type: "user", time: { created: 1 } },
60
+ { id: "answer", type: "assistant", text: "Done.", time: { created: 2, completed: 3 } }]);
61
+ },
62
+ fail(native) {
63
+ native.rows.set(target.upstreamSessionId, [{ id: "input", type: "user", time: { created: 1 } }]);
64
+ observedFailure = Object.assign(new Error(failure), { name: "UnknownError" });
65
+ }
66
+ };
67
+ }
68
+
69
+ function temporaryTurn(runtime, target, conversationId) {
70
+ return runtime.runConversationTurn(target, conversationId, { active: false },
71
+ { id: `${conversationId}:input`, agent: "agent", model: { providerID: "deepseek", id: "model" }, prompt: { text: "Hello" } }, {
72
+ observe: () => ({ waitUntilReady: async () => {}, readFailure: () => null, close: async () => {} }),
73
+ completion: { projectResult: value => value, readError: () => "" },
74
+ onCompletion() {}
75
+ });
76
+ }
77
+
78
+ test("an OpenCode connection change leaves running work alone and reloads idle directories at once", { timeout: 15_000 }, async t => {
79
+ const saved = [deepseek("old", "old-key"), { ...deepseek("zai-account", "zai-key"), modelProviderId: "zai" }];
80
+ const { runtime, native, shared } = await startedRuntime(saved);
81
+ const running = runningTurn(t, runtime, shared, "/work/busy");
82
+ // Another provider's retained conversation, which once refused the change.
83
+ const peer = await runtime.acquire("peer", async () => ({ key: "peer", workdir: "/work/idle", modelProviderId: "zai",
84
+ abortController: new AbortController(), server: openCodeServerForDirectory(shared.server, "/work/idle") }));
85
+ saved[0] = deepseek("new", "new-key");
86
+ let published = false;
87
+ const changed = await runtime.invalidateRuntimes({ modelProviderId: "deepseek", publish() { published = true; } },
88
+ { release: {}, connections: async () => saved });
89
+ await delay(10);
90
+ assert.deepEqual(changed, { closed: 0, ok: true });
91
+ assert.equal(published, true);
92
+ assert.deepEqual(native.calls, [["key", "deepseek", "new-key"], ["reload", "/work/idle"]]);
93
+ assert.deepEqual(native.stops, []);
94
+ assert.equal(runtime.current, shared);
95
+ assert.equal(running.turn.active, true);
96
+ assert.equal(running.target.abortController.signal.aborted, false);
97
+ assert.equal(runtime.processes.get("peer"), peer);
98
+ assert.equal(peer.abortController.signal.aborted, false);
99
+ running.answer(native);
100
+ assert.equal((await running.monitor).state, "completed");
101
+ await delay(10);
102
+ assert.deepEqual(native.calls.slice(2), [["reload", "/work/busy"]], "The busy directory reloads once its turn ends.");
103
+ });
104
+
105
+ test("new work in a directory with a turn in flight starts after that turn ends and the directory reloads", { timeout: 15_000 }, async t => {
106
+ const saved = [deepseek("old", "old-key")];
107
+ const { runtime, native, shared } = await startedRuntime(saved);
108
+ const running = runningTurn(t, runtime, shared, "/work/busy");
109
+ saved[0] = deepseek("new", "new-key");
110
+ await runtime.invalidateRuntimes({ modelProviderId: "deepseek", publish() {} }, { release: {}, connections: async () => saved });
111
+ const target = { workdir: "/work/busy", abortController: new AbortController(),
112
+ server: openCodeServerForDirectory(shared.server, "/work/busy") };
113
+ const elsewhere = { workdir: "/work/other", abortController: new AbortController(),
114
+ server: openCodeServerForDirectory(shared.server, "/work/other") };
115
+ const waiting = temporaryTurn(runtime, target, "temporary");
116
+ assert.equal((await temporaryTurn(runtime, elsewhere, "unrelated")).status, "inProgress",
117
+ "Another directory's new work does not wait.");
118
+ await delay(30);
119
+ assert.equal(native.calls.some(([kind, , id]) => kind === "prompt" && id === "temporary"), false);
120
+ running.answer(native);
121
+ await running.monitor;
122
+ assert.equal((await waiting).status, "inProgress");
123
+ const order = native.calls.map(call => call.join(" "));
124
+ assert.ok(order.indexOf("reload /work/busy") < order.indexOf("prompt /work/busy temporary"), order.join("\n"));
125
+ });
126
+
127
+ test("a stale preparation cannot restore an older key, and a saved change reaches the server at the next use", async () => {
128
+ const saved = [deepseek("old", "old-key")];
129
+ const { runtime, native, shared } = await startedRuntime(saved);
130
+ const start = async () => assert.fail("A changed key does not start another server.");
131
+ saved[0] = deepseek("new", "new-key");
132
+ assert.equal(await runtime.ensure(saved[0], start, async () => saved), shared);
133
+ assert.equal(await runtime.ensure(deepseek("old", "old-key"), start, async () => saved), shared);
134
+ assert.deepEqual(native.calls, [["key", "deepseek", "new-key"]]);
135
+ assert.deepEqual(native.stops, []);
136
+ });
137
+
138
+ test("Disconnect removes the key, and a turn whose connection is gone ends with a reconnect message", { timeout: 15_000 }, async t => {
139
+ const saved = [deepseek("old", "old-key")];
140
+ const { runtime, native, shared } = await startedRuntime(saved);
141
+ const outcomes = [];
142
+ const running = runningTurn(t, runtime, shared, "/work/busy", {
143
+ failure: "Model not found: deepseek/model. Did you mean: model?",
144
+ async completeResult(_turn, outcome) { outcomes.push(outcome); return outcome.failure; }
145
+ });
146
+ saved.length = 0;
147
+ await runtime.invalidateRuntimes({ modelProviderId: "deepseek", publish() {} }, { release: {}, connections: async () => saved });
148
+ assert.deepEqual(native.calls, [["remove", "deepseek"]]);
149
+ running.fail(native);
150
+ const result = await running.monitor;
151
+ assert.equal(result.state, "failed");
152
+ assert.equal(result.error, "deepseek isn't connected. Connect it again, then send your message again.");
153
+ assert.equal(outcomes[0].credentialFailure, true);
154
+ });
155
+
156
+ test("an open native terminal still refuses the change before anything is published", async () => {
157
+ const saved = [deepseek("old", "old-key")];
158
+ const { runtime, native } = await startedRuntime(saved);
159
+ let published = false;
160
+ saved[0] = deepseek("new", "new-key");
161
+ await assert.rejects(runtime.invalidateRuntimes({ modelProviderId: "deepseek", publish() { published = true; } }, {
162
+ release: { assertConnectionChange() { throw new Error("Close OpenCode native terminals."); } }, connections: async () => saved
163
+ }), /Close OpenCode native terminals/u);
164
+ assert.equal(published, false);
165
+ assert.deepEqual(native.calls, []);
166
+ });
@@ -3,7 +3,9 @@ import { access, mkdtemp, rm } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
6
+ import { setTimeout as delay } from "node:timers/promises";
6
7
  import { createOpenCodeServerProcess, openCodeProcessEnvironment } from "../src/server/conversation/openCodeProcess.js";
8
+ import { createOpenCodeSharedRuntime } from "../src/server/conversation/openCodeRuntime.js";
7
9
 
8
10
  async function fixture(t, options = {}) {
9
11
  const workdir = await mkdtemp(path.join(os.tmpdir(), "jskit-opencode-process-"));
@@ -41,6 +43,8 @@ test("the shared OpenCode server authenticates selected accounts and drains its
41
43
  ] });
42
44
  assert.deepEqual(native.modelProviderIds, ["first", "second"]);
43
45
  assert.deepEqual(f.requests.map(({ url }) => url.pathname), ["/global/health", "/auth/first", "/auth/second"]);
46
+ // Requests without a directory reach the server's working directory, which a credential change reloads.
47
+ assert.deepEqual(native.client.instanceDirectories(), [f.config.workdir]);
44
48
  assert.notEqual(native.environment.OPENCODE_SERVER_PASSWORD, "obsolete-password");
45
49
  assert.equal(native.executionId, "owned-process");
46
50
  assert.deepEqual(native.attachArguments({ conversationId: "ses_one", directory: f.config.workdir }),
@@ -51,6 +55,22 @@ test("the shared OpenCode server authenticates selected accounts and drains its
51
55
  await assert.rejects(access(f.config.privateRoot), { code: "ENOENT" });
52
56
  });
53
57
 
58
+ test("the shared owner stops a server that exits without being stopped at once", async t => {
59
+ const f = await fixture(t);
60
+ const reasons = [];
61
+ const runtime = createOpenCodeSharedRuntime({ onStop: ({ reason }) => reasons.push(reason) });
62
+ // The host wires no failure callback: the owner watches the exit itself.
63
+ const shared = await runtime.ensure(null, async () => ({ server: await createOpenCodeServerProcess(f.config), connections: [] }));
64
+ assert.equal(runtime.current, shared);
65
+ f.exited.resolve({ code: 1 });
66
+ for (let attempt = 0; attempt < 50 && f.stops.length === 0; attempt += 1) await delay(10);
67
+ await delay(20);
68
+ assert.deepEqual(f.stops, ["owned-process"]);
69
+ assert.deepEqual(reasons, ["opencode-server-exited"]);
70
+ assert.equal(runtime.current, null);
71
+ await assert.rejects(access(f.config.privateRoot), { code: "ENOENT" });
72
+ });
73
+
54
74
  test("version mismatch preserves startup evidence and the owned identity if cleanup is unconfirmed", async t => {
55
75
  let confirmed = false;
56
76
  let failure;
@@ -97,6 +117,22 @@ test("an unexpected native exit reports failure after readiness", async t => {
97
117
  await native.stop();
98
118
  });
99
119
 
120
+ test("an OpenCode server that exits before readiness says why", async t => {
121
+ const f = await fixture(t);
122
+ f.config.execution.start = async request => {
123
+ f.starts.push(request);
124
+ return { id: "owned-process", pid: 123, running: false, exited: Promise.resolve({ code: 1 }),
125
+ readLogs: () => ({ stdout: "", stderr: `INFO starting\n\u001b[31mError: Unexpected token in ${"x".repeat(600)}\u001b[0m\n\n` }) };
126
+ };
127
+ await assert.rejects(createOpenCodeServerProcess(f.config), error => {
128
+ assert.equal(error.code, "assistant_opencode_start_failed");
129
+ assert.match(error.message, /^OpenCode exited before its server became ready: Error: Unexpected token in x+…$/u);
130
+ assert.ok(error.message.length < 400, "the reason is one bounded line");
131
+ assert.match(error.details.stderr, /INFO starting/u);
132
+ return true;
133
+ });
134
+ });
135
+
100
136
  test("failed host startup retains its execution identity until cleanup is proven", async t => {
101
137
  const f = await fixture(t, { stop: () => ({ scopeEmpty: false }) });
102
138
  const bindings = [];
@@ -224,6 +224,31 @@ test("an acquired OpenCode process without a main conversation can close without
224
224
  assert.equal(f.stops.length, 1);
225
225
  });
226
226
 
227
+ test("deleting a scoped OpenCode conversation returns its scope's exit proof and keeps none", async () => {
228
+ const f = fixture();
229
+ const target = await f.acquire("ephemeral\0reasoning_1");
230
+ target.sessionId = "reasoning_1";
231
+ const deleted = [];
232
+ target.server = { ...target.server, client: { ...target.server.client, async deleteSession(id) { deleted.push(id); } } };
233
+ const application = {
234
+ closeTerminals: async () => ({ ok: true, closed: 0 }),
235
+ beforeRelease: async () => {},
236
+ onRemoved: async () => {},
237
+ afterRelease: async () => {},
238
+ failure: error => { throw error; }
239
+ };
240
+ const result = await f.runtime.deletePreparedConversation({
241
+ key: "ephemeral\0reasoning_1", conversationId: "native-1", persistent: false, scoped: true,
242
+ process: () => assert.fail("deleting must not start OpenCode"), onRemoved: async () => {},
243
+ cleanupAfterDelete: { sessionId: "reasoning_1", options: {}, application }
244
+ }, { persistent: false });
245
+ assert.deepEqual(deleted, ["native-1"]);
246
+ assert.equal(result.deleted, true);
247
+ assert.equal(result.providerExit.processExitProof.exited, true);
248
+ // A scope is never renewed, so nothing would ever release a kept proof.
249
+ assert.equal(f.runtime.releaseProcessExitProof("reasoning_1").processExitProof, null);
250
+ });
251
+
227
252
  test("an active OpenCode turn with missing native identity still fails instead of claiming Stop", async () => {
228
253
  const runtime = createOpenCodeSharedRuntime();
229
254
  const client = createOpenCodeServerClient({ baseUrl: "http://127.0.0.1:1",
@@ -762,81 +787,3 @@ test("original message monitor clone retains only its registered target's final
762
787
  assert.equal(modelPrompts, 0);
763
788
  assert.deepEqual(errors, []);
764
789
  });
765
-
766
- test("credential publication refuses another provider and unowned consumers before stopping or writing", async () => {
767
- for (const unowned of [false, true]) {
768
- const { runtime, acquire, stops } = fixture();
769
- const selected = await acquire("selected"); selected.modelProviderId = "deepseek";
770
- const peer = await acquire("peer"); peer.modelProviderId = unowned ? "deepseek" : "zai";
771
- let published = false;
772
- const effects = [];
773
- await assert.rejects(runtime.invalidateRuntimes({ modelProviderId: "deepseek", publish() { published = true; } }, {
774
- release: { ownsTarget: target => !unowned || target !== peer,
775
- beforeRelease: async target => effects.push(target.key), onRemoved: async () => {}, failure: error => { throw error; } }
776
- }), { code: "assistant_opencode_connection_in_use", message: /^Close the other OpenCode conversations that use this connection, then retry/u });
777
- assert.equal(published, false);
778
- assert.deepEqual(effects, []);
779
- assert.deepEqual(stops, []);
780
- assert.equal(runtime.processes.get("peer"), peer);
781
- assert.equal(peer.abortController.signal.aborted, false);
782
- assert.equal(selected.abortController.signal.aborted, false);
783
- }
784
- });
785
-
786
- test("credential publication fences new acquisition and ensure across native cleanup and private publication", async () => {
787
- const { runtime, acquire, connection, starts, stops } = fixture();
788
- const selected = await acquire("selected"); selected.modelProviderId = "deepseek";
789
- const publishing = Promise.withResolvers(), finish = Promise.withResolvers();
790
- let saved = "old";
791
- const changed = runtime.invalidateRuntimes({ modelProviderId: "deepseek", async publish() {
792
- assert.equal(stops.length, 1); publishing.resolve(); await finish.promise; saved = "new";
793
- } }, { release: { ownsTarget: () => true, beforeRelease: async () => {}, onRemoved: async () => {}, failure: error => { throw error; } } });
794
- await publishing.promise;
795
- assert.equal(saved, "old");
796
- await assert.rejects(acquire("late"), { code: "assistant_opencode_connection_changing" });
797
- await assert.rejects(runtime.ensure(connection, async () => { throw new Error("must not start"); }), { code: "assistant_opencode_connection_changing" });
798
- assert.equal(starts.length, 1);
799
- finish.resolve();
800
- assert.equal((await changed).ok, true);
801
- assert.equal(saved, "new");
802
- await acquire("retry");
803
- assert.equal(starts.length, 2);
804
- });
805
-
806
- test("credential barrier drains admitted acquisitions and refuses a late peer without native effects", async () => {
807
- const { runtime, stops } = fixture();
808
- const started = Promise.withResolvers(), finish = Promise.withResolvers();
809
- const pending = runtime.acquire("late-peer", async () => {
810
- started.resolve(); await finish.promise;
811
- return { key: "late-peer", modelProviderId: "zai", abortController: new AbortController() };
812
- });
813
- await started.promise;
814
- let published = false;
815
- const changed = runtime.invalidateRuntimes({ modelProviderId: "deepseek", publish() { published = true; } }, {
816
- release: { ownsTarget: () => true, beforeRelease: async () => { throw new Error("must not release"); } }
817
- });
818
- const rejected = assert.rejects(changed, { code: "assistant_opencode_connection_in_use" });
819
- finish.resolve(); await pending; await rejected;
820
- assert.equal(published, false);
821
- assert.deepEqual(stops, []);
822
- });
823
-
824
- test("unconfirmed terminal and failed verified stop keep the prior credential and permit safe retry", async () => {
825
- let terminalOpen = true, stopFails = true, published = false;
826
- const { runtime, acquire, stops } = fixture({ stop: async () => ({ exited: !stopFails }) });
827
- const target = await acquire("selected"); target.modelProviderId = "deepseek";
828
- const input = { modelProviderId: "deepseek", publish() { published = true; } };
829
- const preparation = { release: { ownsTarget: () => true,
830
- assertConnectionChange() { if (terminalOpen) throw new Error("close terminal"); },
831
- beforeRelease: async () => {}, onRemoved: async () => {}, failure: error => { throw error; } } };
832
- await assert.rejects(runtime.invalidateRuntimes(input, preparation), /close terminal/);
833
- assert.equal(target.abortController.signal.aborted, false);
834
- assert.equal(stops.length, 0);
835
- terminalOpen = false;
836
- await assert.rejects(runtime.invalidateRuntimes(input, preparation), { code: "assistant_opencode_stop_unverified" });
837
- assert.equal(published, false);
838
- stopFails = false;
839
- assert.equal((await runtime.invalidateRuntimes(input, preparation)).ok, true);
840
- assert.equal(published, true);
841
- assert.equal(stops.length, 2);
842
- });