@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.
Files changed (59) 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 +49 -12
  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 +105 -58
  12. package/src/server/conversation/claudeTurn.js +179 -59
  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 +234 -29
  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 +13 -1
  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 +225 -64
  32. package/src/server/conversation/providers/claudeDriver.js +3 -3
  33. package/src/server/conversation/providers/codexDriver.js +10 -4
  34. package/src/server/conversation/providers/opencodeDriver.js +4 -2
  35. package/src/server/conversation/runtime.js +72 -2
  36. package/src/server/hosts/codexConfiguration.js +4 -0
  37. package/test/assistantScroll.browser.test.js +169 -10
  38. package/test/claudeLifecycle.test.js +293 -0
  39. package/test/claudeProcess.test.js +195 -51
  40. package/test/claudeTurn.test.js +174 -3
  41. package/test/codexAccountChange.test.js +261 -0
  42. package/test/codexConversationsCheckpoint.test.js +3 -0
  43. package/test/codexEvents.test.js +23 -1
  44. package/test/codexHomeStorage.test.js +57 -0
  45. package/test/codexInteractiveArguments.test.js +9 -0
  46. package/test/codexLifecycleIsolation.test.js +441 -0
  47. package/test/codexModelCatalog.test.js +48 -2
  48. package/test/codexProcess.test.js +21 -0
  49. package/test/codexTemporaryProviderRelease.test.js +180 -0
  50. package/test/codexTurn.test.js +21 -1
  51. package/test/conversationNativeStorage.test.js +57 -0
  52. package/test/conversationRuntime.test.js +66 -0
  53. package/test/conversationSession.test.js +4 -8
  54. package/test/messageDelivery.browser.test.js +18 -2
  55. package/test/messageDelivery.test.js +34 -1
  56. package/test/openCodeClient.test.js +31 -0
  57. package/test/openCodeConnectionChange.test.js +166 -0
  58. package/test/openCodeProcess.test.js +36 -0
  59. package/test/openCodeRuntime.test.js +25 -78
@@ -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
- });