@jskit-ai/assistant-core 0.1.224 → 0.1.226

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.
@@ -8,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.224",
11
+ "@jskit-ai/assistant-core": "0.1.226",
12
12
  "@jskit-ai/http-runtime": "0.1.242",
13
13
  "@jskit-ai/kernel": "0.1.244",
14
14
  "@mdi/js": "^7.4.47",
@@ -26,8 +26,9 @@ const delivery = createAssistantMessageDelivery({ deliver(payload) {
26
26
  } });
27
27
  function accept() {
28
28
  const payload = requests.value.at(-1);
29
- turns.value = [...turns.value, { turnId: payload.messageId, user: { messageId: payload.messageId, text: payload.message },
30
- assistant: { text: "Accepted once." } }];
29
+ const at = new Date().toISOString();
30
+ turns.value = [...turns.value, { turnId: payload.messageId, user: { messageId: payload.messageId, text: payload.message, at },
31
+ assistant: { text: "Accepted once.", at } }];
31
32
  pendingRequest.resolve({ ok: true });
32
33
  }
33
34
  if (recoveryFixture) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.224",
3
+ "version": "0.1.226",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -608,16 +608,18 @@ const displayEntries = computed(() => {
608
608
  }, key);
609
609
  if (!displayed) continue;
610
610
  candidates.push({ key, role, turn: { ...turn, turnId }, message: displayed,
611
+ sortAt: turn.optimistic && turn.optimistic.id === message.messageId ? NaN : Date.parse(message.at),
611
612
  delivery: role === "user" ? userDelivery : null });
612
613
  }
613
614
  }
614
615
  // Native streaming turns and saved authored turns can arrive independently.
615
- // Only dated entries participate; undated notices retain their original slot.
616
- const dated = candidates.filter(entry => Number.isFinite(Date.parse(entry.message.at)))
617
- .sort((a, b) => Date.parse(a.message.at) - Date.parse(b.message.at))[Symbol.iterator]();
616
+ // Local queued timestamps can precede an earlier message's native admission.
617
+ // Keep their supplied positions, along with undated notices.
618
+ const dated = candidates.filter(entry => Number.isFinite(entry.sortAt))
619
+ .sort((a, b) => a.sortAt - b.sortAt)[Symbol.iterator]();
618
620
  const entries = [];
619
621
  for (const candidate of candidates) {
620
- const entry = Number.isFinite(Date.parse(candidate.message.at)) ? dated.next().value : candidate;
622
+ const entry = Number.isFinite(candidate.sortAt) ? dated.next().value : candidate;
621
623
  const previous = entries.at(-1);
622
624
  if (entry.role === "thinking" && previous?.role === "thinking") previous.messages.push({ ...entry.message, key: entry.key });
623
625
  else entries.push({ ...entry, ...(entry.role === "thinking" ? { messages: [{ ...entry.message, key: entry.key }] } : {}) });
@@ -63,6 +63,18 @@ export function conversationAgentRunEvent(previous, normalizedRunId, {
63
63
  return record;
64
64
  }
65
65
 
66
+ /** Explicit offline copy only: the destination has no inherited execution. */
67
+ export function prepareCopiedConversationAgentRun(run, { at }) {
68
+ const active = runStateIsActive(run.state);
69
+ if (!active && run.providerGoalStatus !== "active") return structuredClone(run);
70
+ return conversationAgentRunEvent(run, run.id, {
71
+ event: { at, kind: "development-copy", message: "Copied into a stopped development environment." },
72
+ patch: { state: active ? "interrupted" : run.state, providerPhase: "",
73
+ ...(active ? { providerStatus: "interrupted", pendingUserMessageClientIds: [], finishedAt: at } : {}),
74
+ ...(run.providerGoalStatus === "active" ? { providerGoalStatus: "paused" } : {}) }
75
+ });
76
+ }
77
+
66
78
  /** The original native run occupies its bound conversation segment. Reading a
67
79
  * binding without a run never constructs historical ownership or writes state.
68
80
  */
@@ -180,13 +180,15 @@ export async function stopClaudeCodeProcess({ process: native, executionId, stop
180
180
  return { ...proof, exited: true };
181
181
  }
182
182
 
183
- export async function bindClaudeConversationAccount(entry, { identity, providerId, stop, save }) {
183
+ export async function bindClaudeConversationAccount(entry, { identity, providerId, allowAccountChange = false, stop, save }) {
184
184
  entry.accountIdentities ||= {};
185
185
  if (!nativeAiProvider(providerId) && entry.accountIdentities[providerId] && entry.accountIdentities[providerId] !== identity) {
186
186
  await stop(entry, "The signed-in Claude account changed.");
187
- throw Object.assign(new Error("This conversation belongs to another Claude account. Reconnect that account or start a new session."), {
188
- code: "claude_account_changed"
189
- });
187
+ if (!allowAccountChange) {
188
+ throw Object.assign(new Error("This conversation belongs to another Claude account. Reconnect that account or start a new session."), {
189
+ code: "claude_account_changed"
190
+ });
191
+ }
190
192
  }
191
193
  if (entry.accountIdentity !== identity) {
192
194
  entry.accountIdentity = identity;
@@ -126,8 +126,8 @@ export function createClaudeConversationGoals({ conversationId, readHistory, rea
126
126
  if (!objective || objective.length > 4000 || /^(clear|stop|off|reset|none|cancel)$/iu.test(objective)) {
127
127
  throw goalError("Enter a goal condition of up to 4,000 characters.");
128
128
  }
129
- if (readState().active || (action === "set" && goal && goal.status !== "complete")) {
130
- throw goalError("Stop the current turn and cancel the existing goal before setting another.");
129
+ if (action === "set" && goal && goal.status !== "complete") {
130
+ throw goalError("Cancel the existing goal before setting another.");
131
131
  }
132
132
  await sendGoal({ message: `/goal ${objective}` });
133
133
  } else throw goalError("Unknown Claude goal action.");
@@ -264,7 +264,7 @@ export function createClaudeConversationOwner({
264
264
  const providerId = account.providerId;
265
265
  if (entry.accountIdentity !== identity) entry.nativeTurn.invalidateFinalAssistantResult();
266
266
  try {
267
- await bindClaudeConversationAccount(entry, { identity, providerId,
267
+ await bindClaudeConversationAccount(entry, { identity, providerId, allowAccountChange: account.allowAccountChange === true,
268
268
  stop: (entry, reason) => entry.nativeTurn.stopProcess(reason, { preparing: true }), save: saveAccount });
269
269
  } catch (failure) {
270
270
  if (failure.code !== "claude_account_changed") throw failure;
@@ -1434,6 +1434,11 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
1434
1434
  // A completed main loop can still own background work. There is no
1435
1435
  // generation left to interrupt or second terminal result to wait for.
1436
1436
  if (outcome && inFlight.size === 0) {
1437
+ // The next input owns completion now. A task finishing during host
1438
+ // preparation must not settle this turn using the previous result.
1439
+ outcome = null;
1440
+ acceptedInput = null;
1441
+ clearFinalAssistantResult();
1437
1442
  await publish({ type: "diagnostic", stage: "steering_background_only", backgroundTasks: tasks.size });
1438
1443
  return;
1439
1444
  }
@@ -3,13 +3,13 @@ export { conversationMessageIdentity, conversationMessageVersion, conversationHi
3
3
  export { createMemoryConversationStorage } from "./memoryStorage.js";
4
4
  export { createConversationStorage, createReentrantConversationStorage,
5
5
  createConversationOperationLease, beginConversationOperation, finishConversationOperation } from "./storage.js";
6
- export { conversationAgentRunRecord, conversationAgentRunEvent } from "./agentRun.js";
6
+ export { conversationAgentRunRecord, conversationAgentRunEvent, prepareCopiedConversationAgentRun } from "./agentRun.js";
7
7
  export { createFileConversationStorage } from "./fileStorage.js";
8
8
  export { createConversationStreams } from "./streams.js";
9
9
  export { createConversationSystemPrompt } from "./systemPrompt.js";
10
10
  export { createConversationRuntime } from "./runtime.js";
11
11
  export { normalizeConversationConfiguration } from "./configuration.js";
12
- export { upgradeConversationRuntimeState } from "./runtimeStateUpgrade.js";
12
+ export { upgradeConversationRuntimeState, prepareConversationRuntimeCopy } from "./runtimeStateUpgrade.js";
13
13
  export { createConversationHookBridge } from "./hookBridge.js";
14
14
  export { nativeConversationStoragePolicy } from "./nativeStorage.js";
15
15
  export { createConversationProcessIdentity } from "./localExecution.js";
@@ -248,10 +248,13 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
248
248
  currentConnection?.fingerprint === selected.fingerprint &&
249
249
  currentConnection?.endpointCode === selected.endpointCode
250
250
  )) {
251
+ const probe = AbortSignal.timeout(1_000);
251
252
  try {
252
- await sharedProcess.server.client.health({ signal: AbortSignal.timeout(1_000) });
253
+ await sharedProcess.server.client.health({ signal: probe });
253
254
  return sharedProcess;
254
- } catch {
255
+ } catch (error) {
256
+ // A slow probe is not proof that the shared server stopped working.
257
+ if (probe.aborted) throw error;
255
258
  requireConnectionAvailable();
256
259
  await stop("opencode-health-check-failed");
257
260
  }
@@ -1567,12 +1570,13 @@ export function createOpenCodeSharedRuntime({ onStop = () => {}, scope } = {}) {
1567
1570
  const observedProcess = sharedProcess;
1568
1571
  const observedServer = target.server;
1569
1572
  let healthy = false;
1573
+ const probe = AbortSignal.timeout(1_000);
1570
1574
  try {
1571
- await target.server.client.health({ signal: AbortSignal.timeout(1_000) });
1575
+ await target.server.client.health({ signal: probe });
1572
1576
  healthy = true;
1573
1577
  ready = Boolean(await target.server.client.readSession(target.upstreamSessionId));
1574
1578
  } catch (error) {
1575
- if (healthy && error?.statusCode !== 404) {
1579
+ if ((!healthy && probe.aborted) || (healthy && error?.statusCode !== 404)) {
1576
1580
  throw error;
1577
1581
  }
1578
1582
  if (!healthy) {
@@ -1,4 +1,5 @@
1
1
  import { isAbsolute } from "node:path";
2
+ import { prepareCopiedConversationAgentRun } from "./agentRun.js";
2
3
 
3
4
  const engines = new Set(["api", "claude", "codex", "opencode"]);
4
5
  const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
@@ -198,3 +199,44 @@ export function upgradeConversationRuntimeState({ metadata, conversationLog, ret
198
199
  runtime.lastEngine = lastEngine;
199
200
  return { metadata: { ...metadata, runtime }, changed: true, warnings };
200
201
  }
202
+
203
+ /** Explicit destination-only operation. The caller first stops all copied
204
+ * processes and pauses native goals through their owners; this makes their
205
+ * saved projections inert without opening a provider or replaying requests.
206
+ */
207
+ export async function prepareConversationRuntimeCopy(transaction, { at }) {
208
+ if (typeof at !== "string" || !Number.isFinite(Date.parse(at))) throw new TypeError("A copy timestamp is required.");
209
+ const metadata = structuredClone(await transaction.readMetadata());
210
+ const turns = await Promise.all((await transaction.listTurnIds()).map(id => transaction.readTurn(id)));
211
+ const runtime = metadata.runtime;
212
+ if (runtime) {
213
+ if (runtime.version !== 3) invalid("Upgrade the parent conversation before copying its runtime state.");
214
+ upgradeConversationRuntimeState({ metadata, conversationLog: turns });
215
+ if (runtime.replacement) invalid("Finish the parent conversation changeover before copying it.");
216
+ for (const segment of [...runtime.predecessors, runtime]) {
217
+ if (segment.binding) {
218
+ // Native history identity and paths remain valid in the identical guest
219
+ // layout; process handles belong exclusively to the original machine.
220
+ if (Object.hasOwn(segment.binding, "executionId")) segment.binding.executionId = "";
221
+ if (Object.hasOwn(segment.binding, "processDirectory")) segment.binding.processDirectory = "";
222
+ if (segment.binding.codexAppServerRun) {
223
+ segment.binding.codexAppServerRun = prepareCopiedConversationAgentRun(segment.binding.codexAppServerRun, { at });
224
+ }
225
+ }
226
+ if (segment.nativeTurn) {
227
+ if (segment.nativeTurn.active) Object.assign(segment.nativeTurn, {
228
+ active: false, state: "interrupted", status: "interrupted", runState: "interrupted", phase: "", completedAt: at, updatedAt: at
229
+ });
230
+ if (segment.nativeTurn.goalStatus === "active") segment.nativeTurn.goalStatus = "paused";
231
+ }
232
+ if (segment.request) segment.request.error = "Copied pending input was not replayed. Check its delivery before submitting again.";
233
+ }
234
+ await transaction.writeMetadata(metadata);
235
+ }
236
+ for (const turn of turns) {
237
+ if (turn.metadata?.runtime?.status === "running") {
238
+ await transaction.updateTurnMetadata(turn.turnId, { runtime: { ...turn.metadata.runtime,
239
+ status: "interrupted", error: "Copied into a stopped development environment. This request was not replayed." } });
240
+ }
241
+ }
242
+ }
@@ -1059,6 +1059,25 @@ test("Claude native goal commands use ordinary admission, pause retains the goal
1059
1059
  await assert.rejects(f.conversation.updateGoal({ ...start, messageId: "budget", tokenBudget: 100 }), /token budget/u);
1060
1060
  });
1061
1061
 
1062
+ test("Claude accepts a goal during an active turn through the existing steering path", async t => {
1063
+ const f = await fixture(t);
1064
+ await f.conversation.send({ messageId: "working", text: "wait" });
1065
+ const { segmentId } = await f.conversation.read();
1066
+ assert.equal(await f.conversation.readGoal(), null);
1067
+ const command = { action: "set", objective: "Finish the current work", messageId: "working-goal", expectedSegmentId: segmentId };
1068
+ assert.equal((await f.conversation.updateGoal(command)).status, "accepted");
1069
+ const goal = await f.conversation.readGoal();
1070
+ assert.equal(goal.objective, command.objective);
1071
+ assert.equal(goal.status, "active");
1072
+ assert.equal((await f.conversation.updateGoal(command)).duplicate, true);
1073
+ await assert.rejects(f.conversation.updateGoal({ ...command, messageId: "another-goal", objective: "Replace it" }), /cancel the existing goal/i);
1074
+ const trace = await f.trace();
1075
+ assert.equal(trace.filter(row => row.args?.includes("--print")).length, 1);
1076
+ assert.deepEqual(trace.filter(row => row.frame?.type === "user").map(row => row.frame.message.content),
1077
+ ["wait", "/goal Finish the current work"]);
1078
+ assert.equal(trace.filter(row => row.frame?.request?.subtype === "interrupt").length, 1);
1079
+ });
1080
+
1062
1081
  test("Claude rejects reserved goals and waits for ordinary Send to deliver a replacement briefing", async t => {
1063
1082
  const f = await fixture(t);
1064
1083
  let state = await f.conversation.read();
@@ -135,6 +135,21 @@ test("interruption does not claim process exit or complete work on a control ack
135
135
  assert.equal(turn.read().text, "Resumed");
136
136
  });
137
137
 
138
+ test("Claude steering survives background completion while the follow-up is being prepared", async () => {
139
+ const { turn, send, client, events } = fixture();
140
+ await send();
141
+ await turn.receive({ type: "system", subtype: "task_started", task_id: "build", task_type: "local_bash" });
142
+ await turn.receive({ type: "result", subtype: "success", result: "Build started" });
143
+ client.interrupt = () => assert.fail("The generation has already completed");
144
+ await turn.interruptGeneration(client);
145
+ await turn.receive({ type: "system", subtype: "task_notification", task_id: "build", status: "completed" });
146
+ assert.equal(events.some(event => event.type === "settled"), false, "The reserved steering input still needs admission");
147
+ await send("follow-up");
148
+ await turn.receive({ type: "result", subtype: "success", result: "Revised answer" });
149
+ assert.equal(turn.read().status, "completed");
150
+ assert.equal(turn.read().text, "Revised answer");
151
+ });
152
+
138
153
  test("lost acknowledgement stays uncertain and stop rejects pending admission", async () => {
139
154
  const { turn, client, send } = fixture();
140
155
  client.send = async () => {};
@@ -1,6 +1,8 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { upgradeConversationRuntimeState } from "../src/server/conversation/runtimeStateUpgrade.js";
3
+ import { upgradeConversationRuntimeState, prepareConversationRuntimeCopy } from "../src/server/conversation/runtimeStateUpgrade.js";
4
+ import { createMemoryConversationStorage } from "../src/server/conversation/memoryStorage.js";
5
+ import { codexAppServerTurnStateFromAgentRun } from "../src/server/conversation/codexTurnState.js";
4
6
 
5
7
  const configuration = { systemPrompt: "Retained instructions", model: "saved-model" };
6
8
  const request = { messageId: "uncertain", text: "Authored text", origin: "user", attachments: [{ attachmentId: "file" }],
@@ -19,6 +21,72 @@ const record = (engine = "claude", extra = {}) => ({
19
21
  messages: [{ role: "user", text: "Accepted before model selection", messageId: "old-message" }] }]
20
22
  });
21
23
 
24
+ test("a stopped development copy preserves native identity, history and uncertain input without retaining active execution", async () => {
25
+ const at = "2026-10-15T00:00:00.000Z";
26
+ for (const engine of ["api", "codex", "claude", "opencode"]) {
27
+ const value = record(engine, { version: 3, lastEngine: engine,
28
+ ...(engine === "api" ? {} : { request: { ...request, inspectionOnly: true,
29
+ threadId: engine === "codex" ? "saved-thread" : engine === "claude" ? "saved-conversation" : "saved-session" } }),
30
+ nativeTurn: { active: true, state: "active", phase: "working", goalStatus: "active" } });
31
+ if (value.metadata.runtime.binding) Object.assign(value.metadata.runtime.binding = { ...value.metadata.runtime.binding }, {
32
+ executionId: "parent-process", processDirectory: "/parent/process" });
33
+ if (engine === "codex") value.metadata.runtime.binding.codexAppServerRun = {
34
+ id: "codex_app_server", state: "active", providerThreadId: "saved-thread", providerTurnId: "native-turn",
35
+ providerStatus: "inProgress", providerGoalStatus: "active", pendingUserMessageClientIds: ["pending"], events: []
36
+ };
37
+ const before = structuredClone(value);
38
+ const storage = createMemoryConversationStorage();
39
+ await storage.write("copy", async transaction => {
40
+ await transaction.writeMetadata(value.metadata);
41
+ await transaction.appendMessage("turn", { role: "user", text: "Existing question", messageId: "authored", at,
42
+ turnMetadata: { runtime: { engine, segmentId: `${engine}-segment`, status: "running" } } });
43
+ });
44
+ await storage.write("copy", transaction => prepareConversationRuntimeCopy(transaction, { at }));
45
+ const copied = await storage.read("copy", async transaction => ({
46
+ metadata: await transaction.readMetadata(), turn: await transaction.readTurn("turn")
47
+ }));
48
+ const saved = copied.metadata.runtime;
49
+ assert.deepEqual(value, before, "The parent input is never mutated");
50
+ assert.equal(saved.segmentId, before.metadata.runtime.segmentId);
51
+ assert.deepEqual(saved.configuration, configuration);
52
+ assert.equal(saved.nativeTurn.active, false);
53
+ assert.equal(saved.nativeTurn.goalStatus, "paused");
54
+ assert.equal(copied.turn.metadata.runtime.status, "interrupted");
55
+ assert.equal(copied.turn.messages[0].text, "Existing question");
56
+ if (engine !== "api") {
57
+ assert.equal(saved.binding.executionId, "");
58
+ assert.equal(saved.binding.processDirectory, "");
59
+ assert.equal(saved.request.text, request.text);
60
+ assert.equal(saved.request.inspectionOnly, true);
61
+ assert.deepEqual(saved.request.attachments, request.attachments);
62
+ assert.match(saved.request.error, /not replayed/);
63
+ }
64
+ if (engine === "codex") {
65
+ assert.equal(saved.binding.threadId, "saved-thread");
66
+ const turn = codexAppServerTurnStateFromAgentRun(saved.binding.codexAppServerRun);
67
+ assert.equal(turn.active, false);
68
+ assert.equal(turn.goalStatus, "paused");
69
+ assert.equal(turn.turnId, "native-turn");
70
+ assert.deepEqual(saved.binding.codexAppServerRun.pendingUserMessageClientIds, []);
71
+ }
72
+ await storage.write("copy", transaction => prepareConversationRuntimeCopy(transaction, { at }));
73
+ assert.deepEqual(await storage.read("copy", transaction => transaction.readMetadata()), copied.metadata);
74
+ }
75
+ });
76
+
77
+ test("development copy rejects incomplete changeover and older runtime before writing", async () => {
78
+ for (const metadata of [record().metadata, record("claude", {
79
+ version: 3, lastEngine: "claude", replacement: { ...segment("claude"), request: { operationId: "pending" } }
80
+ }).metadata]) {
81
+ let wrote = false;
82
+ await assert.rejects(prepareConversationRuntimeCopy({
83
+ readMetadata: async () => metadata, listTurnIds: async () => [],
84
+ writeMetadata: async () => { wrote = true; }
85
+ }, { at: "2026-10-15T00:00:00Z" }), /Upgrade the parent|Finish the parent/);
86
+ assert.equal(wrote, false);
87
+ }
88
+ });
89
+
22
90
  test("offline conversion preserves exact native bindings and authored requests without inventing a prompt", () => {
23
91
  const value = record("claude", { predecessors: [segment("codex", { request: structuredClone(request) })] });
24
92
  const before = structuredClone(value);
@@ -54,6 +54,21 @@ test("the shared element renders delayed sends and supplies retry, edit and canc
54
54
  await expect(input).toHaveValue("Retain this.");
55
55
  await expect(page.getByText("Cancel this question.", { exact: true })).toHaveCount(0);
56
56
  await page.goto(`${vite.baseURL}/?delivery=1&queue=1`);
57
+ await send("Admit this first.");
58
+ await send("Keep this queued second.");
59
+ const userRows = page.locator(".assistant-transcript__message--user");
60
+ await expect(userRows.nth(0)).toContainText("Admit this first.");
61
+ await expect(userRows.nth(1)).toContainText("Keep this queued second.");
62
+ await page.getByRole("button", { name: "Accept request" }).click();
63
+ await expect(page.getByText("Pending", { exact: true })).toHaveCount(1);
64
+ await expect(userRows.nth(0)).toContainText("Admit this first.");
65
+ await expect(userRows.nth(1)).toContainText("Keep this queued second.");
66
+ await expect.poll(async () => JSON.parse(await page.locator("[data-requests]").textContent()).length).toBe(2);
67
+ await page.getByRole("button", { name: "Accept request" }).click();
68
+ await expect(page.getByText("Pending", { exact: true })).toHaveCount(0);
69
+ await expect(userRows.nth(0)).toContainText("Admit this first.");
70
+ await expect(userRows.nth(1)).toContainText("Keep this queued second.");
71
+ await page.goto(`${vite.baseURL}/?delivery=1&queue=1`);
57
72
  await send("First steer.");
58
73
  await send("Second steer.");
59
74
  await send("Third steer.");
@@ -3,6 +3,7 @@ import { mkdtemp, readFile, rm, stat } 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 { createOpenCodeSharedRuntime, ensureOpenCodeSession, openCodeServerForDirectory, readOpenCodeEnvironments } from "../src/server/conversation/openCodeRuntime.js";
7
8
  import { createOpenCodeServerClient } from "../src/server/conversation/openCodeClient.js";
8
9
 
@@ -13,7 +14,7 @@ function fixture(options = {}) {
13
14
  const start = async selected => {
14
15
  const server = {
15
16
  client: {
16
- async health() { return { healthy: true }; },
17
+ async health(input) { return options.health ? options.health(input) : { healthy: true }; },
17
18
  async interrupt(id) { return options.interrupt ? options.interrupt(id) : true; },
18
19
  async sessionStatus() { return { type: "idle" }; },
19
20
  forDirectory(directory) { return { ...this, directory }; }
@@ -252,6 +253,75 @@ test("a pending OpenCode session start retains the shared server while another s
252
253
  assert.equal(f.stops.length, 1);
253
254
  });
254
255
 
256
+ test("a timed-out shared health probe preserves active conversations and allows retry", async () => {
257
+ let slow = false;
258
+ const f = fixture({ health: async ({ signal }) => {
259
+ if (slow) await delay(5_000, undefined, { signal });
260
+ return { healthy: true };
261
+ } });
262
+ const main = await f.acquire("main");
263
+ const helper = await f.acquire("helper");
264
+ const turn = { active: true, id: "turn", threadId: "main-thread" };
265
+ const temporary = { active: true, target: helper, conversationId: "helper-thread" };
266
+ f.runtime.turns.set(main.key, turn);
267
+ f.runtime.temporaryConversations.set("helper-thread", temporary);
268
+ const shared = f.runtime.current;
269
+ slow = true;
270
+ await assert.rejects(f.acquire("next"), { name: "AbortError" });
271
+ assert.equal(f.runtime.current, shared);
272
+ assert.equal(f.stops.length, 0);
273
+ assert.equal(f.starts.length, 1);
274
+ assert.equal(main.abortController.signal.aborted, false);
275
+ assert.equal(helper.abortController.signal.aborted, false);
276
+ assert.equal(f.runtime.turns.get(main.key), turn);
277
+ assert.equal(f.runtime.temporaryConversations.get("helper-thread"), temporary);
278
+ assert.equal(turn.active, true);
279
+ assert.equal(temporary.active, true);
280
+ assert.equal(f.runtime.processStarts.size, 0);
281
+ slow = false;
282
+ await f.acquire("next");
283
+ assert.equal(f.starts.length, 1);
284
+ });
285
+
286
+ test("a timed-out readiness probe does not classify the shared process as unhealthy", async () => {
287
+ let slow = false;
288
+ const f = fixture({ health: async ({ signal }) => {
289
+ if (slow) await delay(5_000, undefined, { signal });
290
+ return { healthy: true };
291
+ } });
292
+ const target = await f.acquire("main");
293
+ const selection = { modelProviderId: "provider", modelId: "model", catalogRevision: "catalog" };
294
+ Object.assign(target, { upstream: { id: "thread" }, upstreamSessionId: "thread", selection,
295
+ upstreamSelection: selection, canonicalUrl: f.connection.canonicalUrl,
296
+ connectionFingerprint: f.connection.fingerprint, endpointCode: f.connection.endpointCode });
297
+ target.server.client.readSession = async () => ({ id: "thread" });
298
+ const input = { selection, workdir: target.workdir, connection: f.connection,
299
+ refresh: async () => target, changed: () => new Error("Changed") };
300
+ slow = true;
301
+ await assert.rejects(f.runtime.inspectSessionReadiness(target, input), { name: "AbortError" });
302
+ assert.equal(f.stops.length, 0);
303
+ assert.equal(target.abortController.signal.aborted, false);
304
+ slow = false;
305
+ assert.deepEqual(await f.runtime.inspectSessionReadiness(target, input), {
306
+ ready: true, unhealthyProcess: null, missingUpstream: false
307
+ });
308
+ });
309
+
310
+ test("a refused shared health connection still replaces the stopped server", async () => {
311
+ let refused = false;
312
+ const f = fixture({ health: async () => {
313
+ if (refused) throw new TypeError("fetch failed", { cause: { code: "ECONNREFUSED" } });
314
+ return { healthy: true };
315
+ } });
316
+ await f.acquire("first");
317
+ const original = f.runtime.current;
318
+ refused = true;
319
+ await f.acquire("next");
320
+ assert.equal(f.stops.length, 1);
321
+ assert.equal(f.starts.length, 2);
322
+ assert.notEqual(f.runtime.current, original);
323
+ });
324
+
255
325
  test("OpenCode failed acquisition cleans application state once before stopping an unused server", async () => {
256
326
  const f = fixture();
257
327
  const ready = Promise.withResolvers();