@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
@@ -8,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.223",
11
+ "@jskit-ai/assistant-core": "0.1.225",
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.223",
3
+ "version": "0.1.225",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -6,9 +6,9 @@ const open = ref(false);
6
6
  const objective = ref("");
7
7
  const tokenBudget = ref("");
8
8
  const now = ref(Date.now());
9
- const goal = computed(() => props.state?.goal);
9
+ const goal = computed(() => props.state?.loading || props.state?.available === false ? null : props.state?.goal);
10
10
  const running = computed(() => goal.value?.status === "active");
11
- const label = computed(() => ({
11
+ const label = computed(() => props.state?.loading ? "Loading goal…" : props.state?.available === false ? "Goal unavailable" : ({
12
12
  active: "Goal running", paused: "Goal paused", blocked: "Goal blocked",
13
13
  usageLimited: "Goal waiting for allowance", budgetLimited: "Goal budget reached", complete: "Goal complete"
14
14
  })[goal.value?.status] || "Set goal");
@@ -21,7 +21,7 @@ const elapsed = computed(() => {
21
21
  const minutes = Math.floor(total / 60);
22
22
  return minutes < 60 ? `${minutes}:${seconds}` : `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}:${seconds}`;
23
23
  });
24
- const canSet = computed(() => typeof props.state?.set === "function" && (!goal.value || goal.value.status === "complete"));
24
+ const canSet = computed(() => !props.state?.loading && props.state?.available !== false && typeof props.state?.set === "function" && (!goal.value || goal.value.status === "complete"));
25
25
  const validBudget = computed(() => props.state?.tokenBudgetSupported === false || tokenBudget.value === "" || (Number.isSafeInteger(Number(tokenBudget.value)) && Number(tokenBudget.value) > 0));
26
26
  let timer;
27
27
  onMounted(() => watch(() => [props.state?.enabled, running.value, goal.value?.elapsedSeconds, goal.value?.sampledAt], () => {
@@ -45,7 +45,7 @@ async function setGoal() {
45
45
  <template #activator="{ props: menuProps }">
46
46
  <v-btn v-bind="menuProps" class="assistant-goal" size="small" variant="text" :aria-label="label" :title="[label, elapsed && `${elapsed} running time`, goal?.objective].filter(Boolean).join('\n')">
47
47
  <span v-if="goal" class="assistant-goal__light" :class="{ 'assistant-goal__light--running': running, 'assistant-goal__light--paused': goal.status === 'paused' }" aria-hidden="true" />
48
- <span class="assistant-goal__label">{{ goal ? 'Goal' : 'Set goal' }}</span>
48
+ <span class="assistant-goal__label">{{ state.loading ? 'Loading goal…' : state.available === false ? 'Goal unavailable' : goal ? 'Goal' : 'Set goal' }}</span>
49
49
  <span v-if="elapsed" class="assistant-goal__elapsed" aria-hidden="true">{{ elapsed }}</span>
50
50
  </v-btn>
51
51
  </template>
@@ -54,15 +54,15 @@ async function setGoal() {
54
54
  <p v-if="goal" class="my-2">{{ goal.objective }}</p>
55
55
  <p v-if="goal?.reason" class="text-body-small my-2">{{ goal.reason }}</p>
56
56
  <p v-if="elapsed" class="text-body-small my-2">Running time: {{ elapsed }}</p>
57
- <v-btn v-if="running && state.pause" size="small" :disabled="state.pending" @click="state.pause()">{{ state.pending ? 'Pausing…' : 'Pause goal' }}</v-btn>
58
- <v-btn v-else-if="['paused', 'blocked', 'usageLimited'].includes(goal?.status) && state.resume" size="small" :disabled="state.pending" @click="state.resume()">{{ state.pending ? 'Resuming…' : 'Resume goal' }}</v-btn>
59
- <v-btn v-if="goal && goal.status !== 'complete' && state.cancel" color="error" size="small" variant="text" :disabled="state.pending" @click="state.cancel()">Cancel goal</v-btn>
57
+ <v-btn v-if="running && state.pause" size="small" :disabled="state.pending" @click="state.pause()">{{ state.action === 'pause' ? 'Pausing…' : 'Pause goal' }}</v-btn>
58
+ <v-btn v-else-if="['paused', 'blocked', 'usageLimited'].includes(goal?.status) && state.resume" size="small" :disabled="state.pending" @click="state.resume()">{{ state.action === 'resume' ? 'Resuming…' : 'Resume goal' }}</v-btn>
59
+ <v-btn v-if="goal && goal.status !== 'complete' && state.cancel" color="error" size="small" variant="text" :disabled="state.pending" @click="state.cancel()">{{ state.action === 'cancel' ? 'Cancelling…' : 'Cancel goal' }}</v-btn>
60
60
  <p v-if="running || goal?.status === 'paused'" class="text-body-small mt-2">{{ state.pauseInterruptsTurn ? 'Pause stops the current turn and keeps the goal for later.' : 'Pausing prevents further automatic turns. Use Stop to interrupt the current turn.' }}</p>
61
61
  <p v-if="goal && goal.status !== 'complete' && state.cancel" class="text-body-small mt-2">{{ state.cancelInterruptsTurn ? 'Cancel stops the current turn and clears the goal.' : 'Cancel removes this goal. Use Stop to interrupt a turn already running.' }}</p>
62
62
  <form v-if="canSet" class="assistant-goal__form mt-2" @submit.prevent="setGoal">
63
63
  <v-textarea v-model="objective" label="Goal objective" rows="2" maxlength="4000" auto-grow :disabled="state.pending" hide-details />
64
64
  <v-text-field v-if="state.tokenBudgetSupported !== false" v-model="tokenBudget" label="Token budget (optional)" type="number" min="1" step="1" :disabled="state.pending" :error="!validBudget" hide-details />
65
- <v-btn type="submit" size="small" :disabled="state.pending || !objective.trim() || !validBudget">{{ state.pending ? 'Starting…' : 'Start goal' }}</v-btn>
65
+ <v-btn type="submit" size="small" :disabled="state.pending || !objective.trim() || !validBudget">{{ state.action === 'set' ? 'Starting…' : 'Start goal' }}</v-btn>
66
66
  </form>
67
67
  <p v-if="state.error" role="alert" class="text-error text-body-small mt-2">{{ state.error }}</p>
68
68
  <slot :goal="goal" />
@@ -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
  */
@@ -68,14 +68,14 @@ async function claudeHistoryPath({ configRoot, workdir, conversationId }) {
68
68
  return null;
69
69
  }
70
70
 
71
- function claudeMessageBlocks(frame, { includeNested = false } = {}) {
71
+ function claudeMessageBlocks(frame, { includeNested = false, commentary = false } = {}) {
72
72
  if (frame.type !== "assistant" || frame.parent_tool_use_id && !includeNested) return [];
73
73
  const content = frame.message?.content;
74
74
  if (!Array.isArray(content)) return [];
75
75
  // Native frames split one API message into blocks with distinct UUIDs.
76
76
  const messageId = frame.uuid || frame.message.id;
77
77
  if (!messageId) return [];
78
- const commentary = content.some((block) => block.type === "tool_use");
78
+ commentary ||= content.some((block) => block.type === "tool_use");
79
79
  return content.flatMap((block, index) => {
80
80
  const text = block.type === "thinking" ? block.thinking : block.type === "text" ? block.text : "";
81
81
  return typeof text === "string" && text.trim() ? [{
@@ -125,6 +125,12 @@ async function readClaudeHistory(options) {
125
125
  leafUuid = byId.get(leafUuid)?.parentUuid;
126
126
  }
127
127
  }
128
+ const toolMessages = new Set();
129
+ for (const frame of frames) {
130
+ if (rewound && frame.uuid && !retained.has(frame.uuid)) continue;
131
+ if (frame.type === "assistant" && !frame.parent_tool_use_id && frame.message?.id &&
132
+ frame.message.content?.some(block => block.type === "tool_use")) toolMessages.add(frame.message.id);
133
+ }
128
134
  for (const frame of frames) {
129
135
  if (rewound && frame.uuid && !retained.has(frame.uuid)) continue;
130
136
  const status = frame.attachment;
@@ -175,7 +181,7 @@ async function readClaudeHistory(options) {
175
181
  }
176
182
  }
177
183
  }
178
- for (const message of claudeMessageBlocks(frame)) {
184
+ for (const message of claudeMessageBlocks(frame, { commentary: toolMessages.has(frame.message?.id) })) {
179
185
  messages.set(message.id, { ...message, userId: lastUserId });
180
186
  }
181
187
  }
@@ -220,10 +220,17 @@ export function claudeCatalogueModels(initialization) {
220
220
  export function createClaudeAccountQueries({ createProcess, accountIdentity, processes = () => [], isClosing = () => false }) {
221
221
  const error = message => Object.assign(new Error(message), { code: "claude_account_query_failed" });
222
222
  const accountProcessStops = new Set();
223
+ const accountReads = new Set();
223
224
  let catalog;
224
- let catalogStart;
225
+ const catalogStarts = new Map();
225
226
  let planUsage;
226
- let planUsagePending;
227
+ const planUsagePending = new Map();
228
+ let revision = 0;
229
+
230
+ function assertCurrent(expectedRevision) {
231
+ if (expectedRevision !== revision) throw error("Claude account query was cancelled for reconnection.");
232
+ if (isClosing()) throw error("Claude is reconnecting.");
233
+ }
227
234
 
228
235
  async function stopAccountProcess(native) {
229
236
  accountProcessStops.add(native);
@@ -231,50 +238,73 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
231
238
  accountProcessStops.delete(native);
232
239
  }
233
240
 
234
- async function readAccountProcess(identity, read) {
235
- if (isClosing()) throw error("Claude is reconnecting.");
241
+ async function readAccountProcess(identity, expectedRevision, read) {
242
+ assertCurrent(expectedRevision);
236
243
  for (const native of accountProcessStops) await stopAccountProcess(native);
244
+ assertCurrent(expectedRevision);
237
245
  const owned = [...processes()].find((entry) =>
238
246
  entry.process && !entry.stopping && entry.accountIdentity === identity);
239
- if (owned) return read(owned.process);
240
- const native = await createProcess();
247
+ const controller = new AbortController();
248
+ accountReads.add(controller);
249
+ const cancelled = Promise.withResolvers();
250
+ const abort = () => cancelled.reject(controller.signal.reason);
251
+ controller.signal.addEventListener("abort", abort, { once: true });
252
+ // Startup owns its abort and exit proof before it can return a process.
253
+ // Observe cancellation while awaiting that factory as well.
254
+ cancelled.promise.catch(() => {});
255
+ let native;
241
256
  try {
242
- return await read(native);
257
+ native = owned?.process || await createProcess({ signal: controller.signal });
258
+ controller.signal.throwIfAborted();
259
+ return await Promise.race([read(native), cancelled.promise]);
243
260
  } finally {
244
- await stopAccountProcess(native);
261
+ accountReads.delete(controller);
262
+ controller.signal.removeEventListener("abort", abort);
263
+ if (native && !owned) await stopAccountProcess(native);
245
264
  }
246
265
  }
247
266
 
248
267
  return Object.freeze({
249
268
  async readCatalogue(context) {
250
- if (isClosing()) throw error("Claude is reconnecting.");
269
+ const expectedRevision = revision;
270
+ assertCurrent(expectedRevision);
251
271
  const identity = await accountIdentity(context);
272
+ assertCurrent(expectedRevision);
252
273
  if (!catalog || catalog.identity !== identity || Date.now() - catalog.at > 600_000) {
253
- catalogStart ||= (async () => {
254
- const value = await readAccountProcess(identity, (native) => native.initialization);
274
+ if (!catalogStarts.has(identity)) catalogStarts.set(identity, (async () => {
275
+ const value = await readAccountProcess(identity, expectedRevision, (native) => native.initialization);
255
276
  if (identity !== await accountIdentity(context)) throw error("The signed-in Claude account changed. Refresh the model list.");
277
+ assertCurrent(expectedRevision);
256
278
  catalog = { at: Date.now(), identity, value };
257
- })().finally(() => { catalogStart = null; });
258
- await catalogStart;
279
+ return value;
280
+ })().finally(() => { catalogStarts.delete(identity); }));
281
+ return catalogStarts.get(identity);
259
282
  }
260
283
  return catalog.value;
261
284
  },
262
285
  async readPlanUsage(context) {
286
+ const expectedRevision = revision;
287
+ assertCurrent(expectedRevision);
263
288
  const identity = await accountIdentity(context);
289
+ assertCurrent(expectedRevision);
264
290
  if (planUsage?.identity === identity && Date.now() - planUsage.checkedAt < 60_000) return planUsage;
265
- if (planUsagePending) return planUsagePending;
266
- planUsagePending = (async () => {
267
- const value = await readAccountProcess(identity, (native) => native.client.request(
291
+ if (planUsagePending.has(identity)) return planUsagePending.get(identity);
292
+ const pending = (async () => {
293
+ const value = await readAccountProcess(identity, expectedRevision, (native) => native.client.request(
268
294
  { subtype: "get_usage", skip_behaviors: true }));
269
295
  if (identity !== await accountIdentity(context)) return { status: "unavailable", windows: [] };
296
+ assertCurrent(expectedRevision);
270
297
  planUsage = { ...claudePlanUsage(value), identity };
271
298
  return planUsage;
272
- })().finally(() => { planUsagePending = null; });
273
- return planUsagePending;
299
+ })().finally(() => { planUsagePending.delete(identity); });
300
+ planUsagePending.set(identity, pending);
301
+ return pending;
274
302
  },
275
303
  invalidateUsage() { planUsage = null; },
276
304
  async invalidate() {
277
- await Promise.allSettled([catalogStart, planUsagePending]);
305
+ revision++;
306
+ for (const controller of accountReads) controller.abort(error("Claude account query was cancelled for reconnection."));
307
+ await Promise.allSettled([...catalogStarts.values(), ...planUsagePending.values()]);
278
308
  catalog = null;
279
309
  planUsage = null;
280
310
  for (const native of accountProcessStops) await stopAccountProcess(native);
@@ -313,7 +343,8 @@ export async function createClaudeCodeProcess({
313
343
  env: environment, stream: true, limits: executionLimits });
314
344
  await onStarted?.(native.id, stop);
315
345
  signal?.throwIfAborted();
316
- client = createClaudeJsonClient({ stream: Duplex.from({ readable: native.stdout, writable: native.stdin }), onEvent, onFailure,
346
+ const stream = Duplex.from({ readable: native.stdout, writable: native.stdin });
347
+ client = createClaudeJsonClient({ stream, onEvent, onFailure,
317
348
  onControlRequest(request, context) {
318
349
  if (request.subtype === "hook_callback" && commandWrapper && request.callback_id === "jskit-command-wrapper") {
319
350
  return nativeCommandHook(commandWrapper, request.input);
@@ -334,6 +365,15 @@ export async function createClaudeCodeProcess({
334
365
  });
335
366
  })
336
367
  ]) : initializing);
368
+ // Children can retain stdout after the parent exits. Keep observing the
369
+ // execution receipt after startup instead of waiting forever for pipe EOF.
370
+ if (native.exited) void native.exited.then(processExit => {
371
+ if (!stopped && !stopping) stream.destroy(Object.assign(new Error("Claude exited unexpectedly."), {
372
+ code: "assistant_claude_observation_lost", processExit
373
+ }));
374
+ }, error => {
375
+ if (!stopped && !stopping) stream.destroy(error);
376
+ });
337
377
  return { client, initialization, executionId: native.id, stop };
338
378
  } catch (error) {
339
379
  // A rejected start may already own an execution and its cleanup proof.