@jskit-ai/assistant-core 0.1.223 → 0.1.225

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 (29) hide show
  1. package/examples/conversation/package.json +1 -1
  2. package/fixtures/responsive-assistant/DeliveryFixture.vue +3 -2
  3. package/package.json +1 -1
  4. package/src/client/conversation/AssistantGoalControl.vue +8 -8
  5. package/src/client/conversation/AssistantTranscript.vue +6 -4
  6. package/src/server/conversation/agentRun.js +12 -0
  7. package/src/server/conversation/claudeHistory.js +9 -3
  8. package/src/server/conversation/claudeProcess.js +60 -20
  9. package/src/server/conversation/claudeTurn.js +190 -41
  10. package/src/server/conversation/codexProvider.js +67 -3
  11. package/src/server/conversation/index.js +2 -2
  12. package/src/server/conversation/openCodeRuntime.js +8 -4
  13. package/src/server/conversation/providers/claude.js +5 -1
  14. package/src/server/conversation/providers/claudeDriver.js +5 -1
  15. package/src/server/conversation/providers/codex.js +325 -216
  16. package/src/server/conversation/providers/codexDriver.js +14 -1
  17. package/src/server/conversation/providers/scoped.js +39 -23
  18. package/src/server/conversation/runtime.js +20 -1
  19. package/src/server/conversation/runtimeStateUpgrade.js +42 -0
  20. package/test/claudeConversationSession.test.js +3 -2
  21. package/test/claudeProcess.test.js +139 -1
  22. package/test/claudeTurn.test.js +253 -3
  23. package/test/codexConversationSession.test.js +27 -4
  24. package/test/codexGoal.test.js +152 -0
  25. package/test/conversationRuntime.test.js +168 -4
  26. package/test/conversationRuntimeStateUpgrade.test.js +69 -1
  27. package/test/conversationSession.test.js +48 -0
  28. package/test/messageDelivery.browser.test.js +15 -0
  29. package/test/openCodeRuntime.test.js +71 -1
@@ -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 { 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
+ }
@@ -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
+ }