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