@jskit-ai/assistant-core 0.1.222 → 0.1.224

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.
@@ -26,7 +26,7 @@ const goalCapabilities = Object.freeze({ goals: true, goalBudgets: true,
26
26
  goalCommands: Object.freeze({
27
27
  set: Object.freeze({ delivery: "control", interruptsTurn: false }),
28
28
  resume: Object.freeze({ delivery: "control", interruptsTurn: false }),
29
- pause: Object.freeze({ delivery: "control", interruptsTurn: false }),
29
+ pause: Object.freeze({ delivery: "control", interruptsTurn: true }),
30
30
  cancel: Object.freeze({ delivery: "control", interruptsTurn: false })
31
31
  }) });
32
32
  const nativeInput = input => [
@@ -216,6 +216,12 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
216
216
  async ensureConversation({ sessionId, native }) {
217
217
  return native.runOwner.ensureSessionReadiness(sessionId, await native.preparation.readiness());
218
218
  },
219
+ async updateConversationSettings({ sessionId, native, context, input }) {
220
+ const acquired = await native.runOwner.conversationContext(sessionId, { conversationId: input.threadId }, context);
221
+ if (acquired.ok === false) return acquired;
222
+ await acquired.provider.updateThreadSettings(input.threadId, { model: input.model, effort: input.effort });
223
+ return { ok: true };
224
+ },
219
225
  async createConversation({ sessionId, native, input, context }) {
220
226
  return native.runOwner.createConversation(sessionId, input, context);
221
227
  },
@@ -931,6 +937,12 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
931
937
  });
932
938
  if (result.ok !== false) binding.goal = result.goal || null;
933
939
  } finally { admission.release(); }
940
+ if (result.ok !== false && input.action === "pause") {
941
+ // Pause keeps the goal and uses the original verified Stop owner
942
+ // to stop its current conversation turn as well as scheduling.
943
+ const stopped = await interrupt({ configuration, context });
944
+ if (stopped?.ok === false) throw Object.assign(new Error(stopped.error), stopped, { cleanupFailed: true });
945
+ }
934
946
  if (nativeContext.completeResult) await nativeContext.completeResult(result);
935
947
  return { goal: goalValue(result.goal), nativeResult: result };
936
948
  }
@@ -949,6 +961,7 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
949
961
  prepareConversation: () => goalContext(configuration, context, signal, { prepareThread: true, tools })
950
962
  });
951
963
  if (result.ok === false) throw new Error(result.error);
964
+ if (input.action === "pause") await interrupt({ configuration, context });
952
965
  return goalValue(result.goal);
953
966
  },
954
967
  async inspectAdmission({ messageId, threadId, nativeTurnId, configuration, context, goal: command, representation, verifyAccount = false, receiptBinding }) {
@@ -107,12 +107,48 @@ function streamMessage({ sessionId, store, projection, publish }, context, recor
107
107
  }).catch(() => {});
108
108
  }
109
109
 
110
+ // Saved transcript projection is also used while a host's source lease is busy.
111
+ // Keep provider observation and transcript writes in readPersistentConversation.
112
+ async function projectPersistentConversation(options, context, record, response, turns) {
113
+ const { sessionId, store, projection, receipts, includeAccess = false, transcriptQuery } = options;
114
+ const scope = { sessionId, conversationId: record.conversationId };
115
+ const messages = turns.flatMap((turn) => turn.messages.map((message) => ({
116
+ ...message,
117
+ id: message.messageId || `${turn.turnId}:${message.role}:${message.at}`,
118
+ ...(message.role === "user" ? { attachments: turn.user?.attachments || [] } : {}),
119
+ ...(message.role === "user" && !receipts.persisted(turn) &&
120
+ !(response.admitted === true && message.messageId === record.messageId) ? { receipt: false } : {}),
121
+ status: "completed",
122
+ ...projection.messageMetadata(turn)
123
+ })));
124
+ for (const message of response.messages || []) {
125
+ if (message.complete === false && !messages.some((saved) => saved.id === message.id)) {
126
+ const projected = projection.message(message, record, "incomplete");
127
+ if (projected) messages.push({ ...projected, status: response.status });
128
+ }
129
+ }
130
+ return projection.snapshot({ context, record, response, messages, includeAccess,
131
+ readPage: transcriptQuery ? async () => {
132
+ const page = await store.readConversationLogPage(scope, transcriptQuery);
133
+ return { ...page, conversationLog: qualifyTranscript(page.conversationLog, record, response, receipts) };
134
+ } : null
135
+ });
136
+ }
137
+
138
+ export async function projectStoredPersistentConversation(options) {
139
+ const { sessionId, store, record, projection } = options;
140
+ const context = projection.context(options.context, record);
141
+ const turns = await store.readConversationLog({ sessionId, conversationId: record.conversationId });
142
+ return projectPersistentConversation(options, context, record, {
143
+ status: record.status, runId: record.runId, error: record.error
144
+ }, turns);
145
+ }
146
+
110
147
  // These original persistent-conversation operations run inside the host's
111
148
  // existing write lease. Their record, transcript and native operations are the
112
149
  // same facilities used by its retained conversation; no state is copied here.
113
150
  export async function readPersistentConversation(options) {
114
- const { sessionId, operations, store, records, projection, receipts, publish,
115
- includeAccess = false, transcriptQuery } = options;
151
+ const { sessionId, operations, store, records, projection, publish } = options;
116
152
  let { record } = options;
117
153
  const context = projection.context(options.context, record);
118
154
  const scope = { sessionId, conversationId: record.conversationId };
@@ -154,27 +190,7 @@ export async function readPersistentConversation(options) {
154
190
  store.readConversationStream(scope).messages.length) {
155
191
  await publish(context, record.conversationId, { payload: { conversationStream: store.clearConversationStream(scope) } });
156
192
  }
157
- const messages = turns.flatMap((turn) => turn.messages.map((message) => ({
158
- ...message,
159
- id: message.messageId || `${turn.turnId}:${message.role}:${message.at}`,
160
- ...(message.role === "user" ? { attachments: turn.user?.attachments || [] } : {}),
161
- ...(message.role === "user" && !receipts.persisted(turn) &&
162
- !(response.admitted === true && message.messageId === record.messageId) ? { receipt: false } : {}),
163
- status: "completed",
164
- ...projection.messageMetadata(turn)
165
- })));
166
- for (const message of response.messages || []) {
167
- if (message.complete === false && !messages.some((saved) => saved.id === message.id)) {
168
- const projected = projection.message(message, record, "incomplete");
169
- if (projected) messages.push({ ...projected, status: response.status });
170
- }
171
- }
172
- return projection.snapshot({ context, record, response, messages, includeAccess,
173
- readPage: transcriptQuery ? async () => {
174
- const page = await store.readConversationLogPage(scope, transcriptQuery);
175
- return { ...page, conversationLog: qualifyTranscript(page.conversationLog, record, response, receipts) };
176
- } : null
177
- });
193
+ return projectPersistentConversation(options, context, record, response, turns);
178
194
  }
179
195
 
180
196
  export async function startPersistentConversationTurn(options) {
@@ -1,7 +1,7 @@
1
1
  import { createConversationProviderFactory } from "./provider.js";
2
2
  import { createConversationDeliveryTiming } from "./deliveryTiming.js";
3
3
  import { normalizeConversationConfiguration } from "./configuration.js";
4
- import { runScopedConversationTurn, readPersistentConversation, startPersistentConversationTurn,
4
+ import { runScopedConversationTurn, readPersistentConversation, projectStoredPersistentConversation, startPersistentConversationTurn,
5
5
  inspectPersistentConversationAdmission, preparePersistentConversationChangeover,
6
6
  inspectPersistentConversationDelivery, stopPersistentConversation, deletePersistentConversation } from "./providers/scoped.js";
7
7
  import { isDeepStrictEqual } from "node:util";
@@ -2186,6 +2186,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
2186
2186
  return Object.freeze({
2187
2187
  // Internal host composition uses its existing admission/write lease. These
2188
2188
  // original coordinators share the scoped owner and introduce no entry or queue.
2189
+ async projectStoredPersistentConversation(options) {
2190
+ if (closed) throw failure("This conversation runtime is closed.", "conversation_closed");
2191
+ return projectStoredPersistentConversation(options);
2192
+ },
2189
2193
  async readPersistentConversation(options) {
2190
2194
  if (closed) throw failure("This conversation runtime is closed.", "conversation_closed");
2191
2195
  return readPersistentConversation(options);
@@ -2300,6 +2304,21 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
2300
2304
  }
2301
2305
  return createDriver(prepared.engine, defaultHost).seedRenewalHandover(prepared);
2302
2306
  },
2307
+ async updateNativeConversationSettings({ id, context, input = {} } = {}) {
2308
+ if (typeof id !== "string" || !id.trim()) throw new TypeError("A conversation id is required.");
2309
+ await access(context, id, "configure");
2310
+ if (closed) throw failure("This conversation runtime is closed.", "conversation_closed");
2311
+ const prepared = await defaultHost?.conversation?.({ id, context, input, operation: "updateSettings" });
2312
+ if (prepared?.sessionId !== id || !prepared.native || !Object.hasOwn(prepared, "context")) {
2313
+ throw new TypeError("Native settings require their authorized host preparation.");
2314
+ }
2315
+ const driver = createDriver(prepared.engine, defaultHost);
2316
+ if (typeof driver.updateConversationSettings !== "function") {
2317
+ throw failure("This provider does not support live native settings.", "conversation_unsupported");
2318
+ }
2319
+ if (closed) throw failure("This conversation runtime is closed.", "conversation_closed");
2320
+ return driver.updateConversationSettings({ ...prepared, input });
2321
+ },
2303
2322
  // Readiness uses the same authorized host and original native owner without
2304
2323
  // opening a retained handle or starting a conversational turn.
2305
2324
  async ensureNativeConversation({ id, context } = {}) {
@@ -1,4 +1,5 @@
1
1
  import { normalizeText } from "./normalize.js";
2
+ import { conversationLogPage } from "../../shared/conversation/pagination.js";
2
3
 
3
4
  /** Conversation policy, independent of database, filesystem, identity and HTTP. */
4
5
  export function createConversationTranscript({ storage, clock = () => new Date(), applicationTurns = false } = {}) {
@@ -15,30 +16,43 @@ export function createConversationTranscript({ storage, clock = () => new Date()
15
16
  });
16
17
  }
17
18
 
18
- async function readConversationLogPage(scope, { beforeTurnId = "", limit = 0, presentation = false } = {}) {
19
+ async function readConversationLogPage(scope, { beforeTurnId = "", limit = 0, presentation = false, pageUnit = "turns" } = {}) {
19
20
  if (typeof presentation !== "boolean") throw new TypeError("Transcript presentation requires an explicit boolean.");
21
+ if (!["turns", "messages"].includes(pageUnit)) throw new TypeError("Transcript pageUnit must be turns or messages.");
20
22
  return storage.read(scope, async (transaction) => {
21
23
  const allIds = await transaction.listTurnIds();
22
- const visible = presentation ? (await Promise.all(allIds.map(id => transaction.readTurn(id))))
23
- .filter(turn => hasMessages(turn) && !isCompletedEnvelopeTurn(turn)) : null;
24
+ const visible = presentation || pageUnit === "messages"
25
+ ? (await Promise.all(allIds.map(id => transaction.readTurn(id))))
26
+ .filter(turn => hasMessages(turn) && (!presentation || !isCompletedEnvelopeTurn(turn))) : null;
24
27
  const ids = visible ? visible.map(turn => turn.turnId) : allIds;
25
28
  const before = normalizeText(beforeTurnId);
26
29
  const requestedLimit = Number.parseInt(String(limit || ""), 10);
27
30
  const pageLimit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, 100) : 0;
28
- const end = before && ids.includes(before) ? ids.indexOf(before) : ids.length;
29
- const start = pageLimit ? Math.max(0, end - pageLimit) : 0;
30
- const selected = ids.slice(start, end);
31
- const turns = visible ? visible.slice(start, end)
32
- : (await Promise.all(selected.map((id) => transaction.readTurn(id)))).filter(hasMessages);
31
+ let page;
32
+ if (pageUnit === "messages") {
33
+ page = conversationLogPage(visible, { beforeTurnId: before, limit: pageLimit });
34
+ } else {
35
+ const end = before && ids.includes(before) ? ids.indexOf(before) : ids.length;
36
+ const start = pageLimit ? Math.max(0, end - pageLimit) : 0;
37
+ const selected = ids.slice(start, end);
38
+ page = {
39
+ conversationLog: visible ? visible.slice(start, end)
40
+ : (await Promise.all(selected.map(id => transaction.readTurn(id)))).filter(hasMessages),
41
+ hasMoreBefore: start > 0,
42
+ nextBeforeTurnId: start > 0 ? ids[start] : ""
43
+ };
44
+ }
45
+ const turns = page.conversationLog;
33
46
  return {
34
47
  conversationLog: turns,
35
48
  pagination: {
49
+ ...(pageUnit === "messages" ? { pageUnit } : {}),
36
50
  beforeTurnId: before,
37
51
  count: turns.length,
38
- hasMoreBefore: start > 0,
52
+ hasMoreBefore: page.hasMoreBefore,
39
53
  limit: pageLimit,
40
54
  newestTurnId: turns.at(-1)?.turnId || "",
41
- nextBeforeTurnId: start > 0 ? turns[0]?.turnId || selected[0] || "" : "",
55
+ nextBeforeTurnId: page.nextBeforeTurnId,
42
56
  oldestTurnId: turns[0]?.turnId || "",
43
57
  totalTurnCount: ids.length
44
58
  }
@@ -1,5 +1,78 @@
1
1
  // Original paged transcript representation, shared by all browser bindings.
2
- const CONVERSATION_LOG_PAGE_LIMIT = 20;
2
+ const CONVERSATION_LOG_PAGE_LIMIT = 10;
3
+
4
+ const messageRoles = ["system", "user", "assistant"];
5
+
6
+ // Live updates retain whole boundary turns. Reasoning/progress never uses the
7
+ // message allowance, and never evicts the question it follows.
8
+ function conversationLogPageStart(turns, limit, end = turns.length) {
9
+ if (!limit) return 0;
10
+ let remaining = limit;
11
+ let start = end;
12
+ while (start > 0 && remaining > 0) {
13
+ start -= 1;
14
+ remaining -= messageRoles.filter(role => turns[start]?.[role]).length;
15
+ }
16
+ return turns.slice(0, start).some(turn => messageRoles.some(role => turn?.[role])) ? start : 0;
17
+ }
18
+
19
+ // A cursor can stop between the question and answer of one stored turn. It is
20
+ // read-only presentation state; stored turn and message identities never change.
21
+ function conversationLogPage(turns, { beforeTurnId = "", limit = 0 } = {}) {
22
+ const messages = turns.flatMap((turn, index) => messageRoles.filter(role => turn[role])
23
+ .map(role => ({ index, role, turnId: turn.turnId })));
24
+ let boundary = turns.findIndex(turn => turn.turnId === beforeTurnId);
25
+ let beforeRole = "";
26
+ if (boundary < 0 && beforeTurnId.startsWith("message:")) {
27
+ try {
28
+ const [id, role] = JSON.parse(beforeTurnId.slice(8));
29
+ if (messageRoles.includes(role)) {
30
+ boundary = turns.findIndex(turn => turn.turnId === id);
31
+ beforeRole = role;
32
+ }
33
+ } catch { /* An unknown cursor retains the existing latest-page behavior. */ }
34
+ }
35
+ const beforeRoleIndex = beforeRole ? messageRoles.indexOf(beforeRole) : 0;
36
+ const foundEnd = boundary < 0 ? -1 : messages.findIndex(message =>
37
+ message.index > boundary ||
38
+ (message.index === boundary && messageRoles.indexOf(message.role) >= beforeRoleIndex));
39
+ const end = foundEnd < 0 ? messages.length : foundEnd;
40
+ const start = limit > 0 ? Math.max(0, end - limit) : 0;
41
+ const first = messages[start];
42
+ const firstRoleIndex = start > 0 ? messageRoles.indexOf(first.role) : 0;
43
+ const startTurn = start > 0 ? first.index : 0;
44
+ const endTurn = boundary < 0 ? turns.length : boundary + (beforeRole ? 1 : 0);
45
+ const selected = turns.slice(startTurn, endTurn).map((turn, offset) => {
46
+ const index = startTurn + offset;
47
+ const roles = messageRoles.filter(role => turn[role]);
48
+ const fromRole = index === startTurn ? firstRoleIndex : 0;
49
+ const toRole = index === boundary && beforeRole ? beforeRoleIndex : messageRoles.length;
50
+ const keep = roles.filter(role => {
51
+ const position = messageRoles.indexOf(role);
52
+ return position >= fromRole && position < toRole;
53
+ });
54
+ if (keep.length === roles.length) return turn;
55
+ // Progress belongs to the later part of a split turn, next to its answer.
56
+ const keepActivity = keep.includes(roles.at(-1));
57
+ const result = { ...turn, paginationPartial: true,
58
+ thinking: keepActivity ? turn.thinking : [], commentary: keepActivity ? turn.commentary : [] };
59
+ for (const role of messageRoles) if (!keep.includes(role)) result[role] = null;
60
+ result.messages = (turn.messages || [turn.system, turn.user, ...(turn.thinking || []),
61
+ ...(turn.commentary || []), turn.assistant].filter(Boolean))
62
+ .filter(message => keep.includes(message.role) || (keepActivity && !messageRoles.includes(message.role)));
63
+ return result;
64
+ }).filter(turn => messageRoles.some(role => turn[role]) || turn.thinking?.length || turn.commentary?.length);
65
+ let nextBeforeTurnId = "";
66
+ if (start > 0) {
67
+ const split = messageRoles.slice(0, firstRoleIndex).some(role => turns[first.index][role]);
68
+ nextBeforeTurnId = split ? `message:${JSON.stringify([first.turnId, first.role])}` : first.turnId;
69
+ }
70
+ return {
71
+ conversationLog: selected,
72
+ hasMoreBefore: start > 0,
73
+ nextBeforeTurnId
74
+ };
75
+ }
3
76
 
4
77
  function isRecord(value) {
5
78
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -8,6 +81,7 @@ function isRecord(value) {
8
81
  function normalizeConversationLogPagination(pagination = {}) {
9
82
  const source = isRecord(pagination) ? pagination : {};
10
83
  return {
84
+ ...(source.pageUnit === "messages" ? { pageUnit: "messages" } : {}),
11
85
  beforeTurnId: String(source.beforeTurnId || "").trim(),
12
86
  count: Number.isFinite(Number(source.count)) ? Number(source.count) : 0,
13
87
  hasMoreBefore: source.hasMoreBefore === true,
@@ -47,7 +121,22 @@ function mergeConversationLogPages(pages = []) {
47
121
  continue;
48
122
  }
49
123
  if (indexes.has(turnId)) {
50
- orderedTurns[indexes.get(turnId)] = turn;
124
+ const index = indexes.get(turnId);
125
+ const previous = orderedTurns[index];
126
+ if (!turn.paginationPartial && !previous.paginationPartial) {
127
+ orderedTurns[index] = turn;
128
+ continue;
129
+ }
130
+ const merged = { ...previous, ...turn };
131
+ for (const role of messageRoles) merged[role] = turn[role] || previous[role];
132
+ const [activity, previousActivity] = [turn, previous].map(part =>
133
+ (part.messages || [...(part.thinking || []), ...(part.commentary || [])])
134
+ .filter(message => !messageRoles.includes(message.role)));
135
+ const retainedActivity = activity.length ? activity : previousActivity;
136
+ merged.thinking = retainedActivity.filter(message => message.role === "thinking");
137
+ merged.commentary = retainedActivity.filter(message => message.role === "commentary");
138
+ merged.messages = [merged.system, merged.user, ...retainedActivity, merged.assistant].filter(Boolean);
139
+ orderedTurns[index] = merged;
51
140
  continue;
52
141
  }
53
142
  indexes.set(turnId, orderedTurns.length);
@@ -71,6 +160,8 @@ function conversationLogReadQuery({
71
160
 
72
161
  export {
73
162
  CONVERSATION_LOG_PAGE_LIMIT,
163
+ conversationLogPageStart,
164
+ conversationLogPage,
74
165
  normalizeConversationLogPagination,
75
166
  normalizeConversationLogPage,
76
167
  mergeConversationLogPages,
@@ -1,5 +1,5 @@
1
1
  import { normalizeThinkingMessageText } from "./thinkingText.js";
2
- import { normalizeConversationLogPagination } from "./pagination.js";
2
+ import { conversationLogPageStart, normalizeConversationLogPagination } from "./pagination.js";
3
3
 
4
4
  function isRecord(value) {
5
5
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -125,14 +125,17 @@ function applyConversationLogPatch(payload = {}, patch = null, options = {}) {
125
125
  numeric: true
126
126
  }
127
127
  ));
128
+ const pagination = normalizeConversationLogPagination(source.pagination);
128
129
  const limit = Number.parseInt(String(options.limit || ""), 10);
129
130
  const limitedTurns = Number.isFinite(limit) && limit > 0
130
- ? nextTurns.slice(-limit)
131
+ ? nextTurns.slice(pagination.pageUnit === "messages" ? conversationLogPageStart(nextTurns, limit) : -limit)
131
132
  : nextTurns;
132
133
  const wasTrimmed = limitedTurns.length < nextTurns.length;
133
- const pagination = normalizeConversationLogPagination(source.pagination);
134
134
  const hasMoreBefore = pagination.hasMoreBefore || wasTrimmed;
135
135
  const oldestTurnId = String(limitedTurns[0]?.turnId || "").trim();
136
+ const beforeCursor = oldestTurnId === pagination.oldestTurnId
137
+ ? pagination.nextBeforeTurnId || oldestTurnId
138
+ : oldestTurnId;
136
139
  return {
137
140
  ...source,
138
141
  conversationLog: limitedTurns,
@@ -141,7 +144,7 @@ function applyConversationLogPatch(payload = {}, patch = null, options = {}) {
141
144
  count: limitedTurns.length,
142
145
  hasMoreBefore,
143
146
  newestTurnId: String(limitedTurns.at(-1)?.turnId || "").trim(),
144
- nextBeforeTurnId: hasMoreBefore ? oldestTurnId : "",
147
+ nextBeforeTurnId: hasMoreBefore ? beforeCursor : "",
145
148
  oldestTurnId
146
149
  }
147
150
  };
@@ -298,6 +298,7 @@ async function fixture(t, options = {}) {
298
298
  if (text === "steering") {
299
299
  answer.message.content[0].text = "Initial progress";
300
300
  answer.message.id = frame.uuid + "-before";
301
+ answer.message.stop_reason = "end_turn";
301
302
  record(answer); emit(answer); return;
302
303
  }
303
304
  if (text === "phases") {
@@ -434,7 +435,7 @@ test("Claude reports common work and compaction phases and clears phase after se
434
435
  assert.deepEqual(phases, ["preparing", "working", "compacting", "working", ""]);
435
436
  });
436
437
 
437
- test("Claude steering interrupts generation and admits a distinct message without replacing its process", async t => {
438
+ test("Claude steering interrupts generation and admits a distinct message without replacing its process", { timeout: 30_000 }, async t => {
438
439
  const f = await fixture(t);
439
440
  const started = Promise.withResolvers();
440
441
  const events = [];
@@ -2097,7 +2098,7 @@ test("completed Claude native-tool tracking refuses steering a retained marked u
2097
2098
  } finally { await f.close(); }
2098
2099
  });
2099
2100
 
2100
- test("completed Claude native-tool tracking preserves ordinary native steering in the opted runtime", async t => {
2101
+ test("completed Claude native-tool tracking preserves ordinary native steering in the opted runtime", { timeout: 30_000 }, async t => {
2101
2102
  const f = await completedEnvelopeFixture(t);
2102
2103
  const progress = Promise.withResolvers();
2103
2104
  await f.conversation.subscribe(event => {
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import os, { tmpdir } from "node:os";
4
4
  import path, { join } from "node:path";
5
5
  import { test } from "node:test";
6
- import { claudePlanUsage, claudeCodeArguments, claudeModelConfiguration, createClaudeCodeProcess, readClaudeCodeAuthStatus, stopClaudeCodeProcess } from "../src/server/conversation/claudeProcess.js";
6
+ import { createClaudeAccountQueries, claudePlanUsage, claudeCodeArguments, claudeModelConfiguration, createClaudeCodeProcess, readClaudeCodeAuthStatus, stopClaudeCodeProcess } from "../src/server/conversation/claudeProcess.js";
7
7
  import { createLocalConversationExecution } from "../src/server/conversation/localExecution.js";
8
8
 
9
9
  test("native command construction preserves tool-free constraints without granting permissions by default", () => {
@@ -278,6 +278,31 @@ test("Claude handshake failure retains acquired execution custody when cleanup i
278
278
  });
279
279
 
280
280
 
281
+ test("Claude observes parent exit after initialization even while a child retains stdout", { timeout: 3_000 }, async t => {
282
+ const { PassThrough, Writable } = await import("node:stream");
283
+ const exited = Promise.withResolvers();
284
+ const failed = Promise.withResolvers();
285
+ const stdout = new PassThrough();
286
+ const stdin = new Writable({ write(chunk, _encoding, done) {
287
+ const frame = JSON.parse(String(chunk));
288
+ stdout.write(JSON.stringify({ type: "control_response", response: { subtype: "success", request_id: frame.request_id, response: {} } }) + "\n");
289
+ done();
290
+ } });
291
+ let failures = 0;
292
+ const native = await createClaudeCodeProcess({ execution: {
293
+ async start() { return { id: "parent-exit", stdin, stdout, exited: exited.promise }; },
294
+ async stop() { return { scopeEmpty: true }; }
295
+ }, onFailure(error) { failures++; failed.resolve(error); } });
296
+ t.after(() => native.stop());
297
+ assert.equal(stdout.readableEnded, false);
298
+ exited.resolve({ code: 1, signal: null });
299
+ const error = await failed.promise;
300
+ assert.equal(error.code, "assistant_claude_observation_lost");
301
+ assert.deepEqual(error.processExit, { code: 1, signal: null });
302
+ await native.client.completion;
303
+ assert.equal(failures, 1);
304
+ });
305
+
281
306
  test("Claude initialization rejects confirmed execution exit before half-open protocol EOF", async () => {
282
307
  const { PassThrough, Writable } = await import("node:stream");
283
308
  for (const cleanup of ["verified", "unconfirmed", "rejected"]) {
@@ -377,3 +402,116 @@ test("Claude supplied execution hosts retain initialization without an optional
377
402
  assert.equal(native.initialization.originalHost, true);
378
403
  assert.deepEqual(await native.stop(), { scopeEmpty: true, exited: true });
379
404
  });
405
+
406
+
407
+ test("Claude account queries isolate concurrent identities and deduplicate each account", async () => {
408
+ for (const method of ["readCatalogue", "readPlanUsage"]) {
409
+ const pending = new Map(["first", "second"].map(id => [id, Promise.withResolvers()]));
410
+ const calls = [];
411
+ const queries = createClaudeAccountQueries({
412
+ accountIdentity: async context => context.id,
413
+ processes: () => [...pending].map(([id, held]) => ({ accountIdentity: id, process: {
414
+ get initialization() { calls.push(id); return held.promise; },
415
+ client: { request() { calls.push(id); return held.promise; } }
416
+ } })),
417
+ createProcess() { throw new Error("An owned account process should be reused."); }
418
+ });
419
+ const first = queries[method]({ id: "first" });
420
+ const duplicate = queries[method]({ id: "first" });
421
+ const second = queries[method]({ id: "second" });
422
+ // Finish in reverse order to expose cross-account sharing and cache races.
423
+ pending.get("second").resolve({ models: [{ value: "second" }], rate_limits_available: true,
424
+ rate_limits: { five_hour: { utilization: 20 } } });
425
+ pending.get("first").resolve({ models: [{ value: "first" }], rate_limits_available: true,
426
+ rate_limits: { five_hour: { utilization: 80 } } });
427
+ const [a, same, b] = await Promise.all([first, duplicate, second]);
428
+ assert.deepEqual(calls, ["first", "second"], method);
429
+ assert.deepEqual(same, a);
430
+ if (method === "readCatalogue") {
431
+ assert.equal(a.models[0].value, "first");
432
+ assert.equal(b.models[0].value, "second");
433
+ } else {
434
+ assert.equal(a.identity, "first");
435
+ assert.equal(b.identity, "second");
436
+ assert.equal(a.windows[0].remainingPercent, 20);
437
+ assert.equal(b.windows[0].remainingPercent, 80);
438
+ }
439
+ await queries.invalidate();
440
+ }
441
+ });
442
+
443
+ test("account invalidation cancels held startup and usage without stopping a borrowed conversation", async () => {
444
+ for (const phase of ["startup", "owned usage", "borrowed usage"]) {
445
+ const started = Promise.withResolvers();
446
+ let stops = 0;
447
+ const native = { client: { request() { started.resolve(); return new Promise(() => {}); } },
448
+ async stop() { stops++; return { scopeEmpty: true }; } };
449
+ const queries = createClaudeAccountQueries({ accountIdentity: async () => "account",
450
+ processes: () => phase === "borrowed usage" ? [{ accountIdentity: "account", process: native }] : [],
451
+ createProcess({ signal }) {
452
+ if (phase !== "startup") return native;
453
+ started.resolve();
454
+ return new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true }));
455
+ }
456
+ });
457
+ const pending = queries.readPlanUsage({});
458
+ const rejected = assert.rejects(pending, /cancelled for reconnection/);
459
+ await started.promise;
460
+ await queries.invalidate();
461
+ await rejected;
462
+ assert.equal(stops, phase === "owned usage" ? 1 : 0, phase);
463
+ }
464
+ });
465
+
466
+ for (const method of ["readCatalogue", "readPlanUsage"]) {
467
+ for (const phase of ["initial identity", "final identity", "retained cleanup"]) {
468
+ test(`${method} invalidation fences ${phase} before launching or caching a stale query`, { timeout: 2_000 }, async () => {
469
+ const entered = Promise.withResolvers();
470
+ const release = Promise.withResolvers();
471
+ let identities = 0;
472
+ let starts = 0;
473
+ let stops = 0;
474
+ const queries = createClaudeAccountQueries({
475
+ async accountIdentity() {
476
+ identities++;
477
+ if (identities === (phase === "initial identity" ? 1 : phase === "final identity" ? 2 : -1)) {
478
+ entered.resolve();
479
+ await release.promise;
480
+ }
481
+ return "account";
482
+ },
483
+ createProcess() {
484
+ starts++;
485
+ return {
486
+ initialization: { models: [{ value: `model-${starts}` }] },
487
+ client: { request: async () => ({ rate_limits_available: true, rate_limits: {} }) },
488
+ async stop() {
489
+ stops++;
490
+ if (phase === "retained cleanup" && stops === 1) return { scopeEmpty: false };
491
+ if (phase === "retained cleanup" && stops === 2) {
492
+ entered.resolve();
493
+ await release.promise;
494
+ }
495
+ return { scopeEmpty: true };
496
+ }
497
+ };
498
+ }
499
+ });
500
+ if (phase === "retained cleanup") await assert.rejects(queries[method]({}), /cleanup could not be confirmed/);
501
+ const pending = queries[method]({}).then(value => ({ value }), failure => ({ failure }));
502
+ await entered.promise;
503
+ const invalidating = queries.invalidate();
504
+ // Initial account lookup has not registered a process or pending query.
505
+ // Let invalidation finish to exercise an old read resuming after reconnect.
506
+ if (phase === "initial identity") await invalidating;
507
+ release.resolve();
508
+ const result = await pending;
509
+ await invalidating;
510
+ assert.match(result.failure?.message || "query unexpectedly succeeded", /cancelled for reconnection/);
511
+ assert.equal(starts, phase === "initial identity" ? 0 : 1, "invalidation must not launch a successor");
512
+ await queries[method]({});
513
+ assert.equal(starts, phase === "initial identity" ? 1 : 2, "a fresh request repopulates the invalidated cache");
514
+ await queries.invalidate();
515
+ });
516
+ }
517
+ }