@jskit-ai/assistant-core 0.1.205 → 0.1.206

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 (39) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/App.vue +12 -2
  3. package/package.json +19 -9
  4. package/src/client/conversation/AssistantTranscript.vue +177 -16
  5. package/src/server/conversation/claudeTurn.js +16 -7
  6. package/src/server/conversation/codexConversations.js +94 -0
  7. package/src/server/conversation/codexProvider.js +15 -5
  8. package/src/server/conversation/codexProviderOwner.js +70 -47
  9. package/src/server/conversation/codexProviderSelection.js +5 -2
  10. package/src/server/conversation/codexRuntimeLifecycle.js +2 -1
  11. package/src/server/conversation/codexTurn.js +10 -1
  12. package/src/server/conversation/codexTurnControl.js +1 -1
  13. package/src/server/conversation/codexTurnRecovery.js +21 -8
  14. package/src/server/conversation/openCodeRuntime.js +107 -64
  15. package/src/server/conversation/openCodeTurn.js +33 -16
  16. package/src/server/conversation/providers/api.js +1 -0
  17. package/src/server/conversation/providers/claudeDriver.js +13 -0
  18. package/src/server/conversation/providers/codex.js +90 -47
  19. package/src/server/conversation/providers/codexDriver.js +4 -1
  20. package/src/server/conversation/providers/opencodeDriver.js +3 -0
  21. package/src/server/conversation/runtime.js +32 -5
  22. package/src/server/hosts/codexEvents.js +15 -0
  23. package/src/server/hosts/codexTurn.js +5 -0
  24. package/src/server/hosts/openCodeClient.js +4 -0
  25. package/src/server/lib/aiConnectionClient.js +9 -11
  26. package/test/aiConnectionClient.test.js +209 -0
  27. package/test/assistantScroll.browser.test.js +255 -1
  28. package/test/claudeConversationSession.test.js +78 -0
  29. package/test/claudeTurn.test.js +18 -0
  30. package/test/codexConversationSession.test.js +318 -0
  31. package/test/codexTurn.test.js +203 -2
  32. package/test/conversationConfiguration.test.js +27 -0
  33. package/test/conversationReplacement.test.js +76 -1
  34. package/test/conversationRuntime.test.js +173 -4
  35. package/test/conversationSession.test.js +139 -0
  36. package/test/conversationTools.test.js +17 -3
  37. package/test/opencodeConversationSession.test.js +200 -1
  38. package/test/packageMetadata.test.js +75 -0
  39. package/test/serviceToolCatalog.test.js +31 -0
@@ -340,7 +340,7 @@ export async function waitForOpenCodeMessages(client, conversationId = "", input
340
340
  if (result?.complete && (await client.sessionStatus(conversationId, { signal })).type === "idle" &&
341
341
  expectedInputMessageId === text(resolveInputMessageId())) {
342
342
  if (completedInputMessageId === expectedInputMessageId) {
343
- return { messages, result };
343
+ return { messages, result, inputMessageId: expectedInputMessageId };
344
344
  }
345
345
  completedInputMessageId = expectedInputMessageId;
346
346
  } else {
@@ -353,18 +353,34 @@ export async function waitForOpenCodeMessages(client, conversationId = "", input
353
353
  export async function waitForOpenCodeFinalResponse(client, conversationId, turn, {
354
354
  agent, model, recoveryMessageId, beforeRecovery = null, ...options
355
355
  } = {}) {
356
- const waitForCompletion = () => waitForOpenCodeMessages(
357
- client, conversationId, () => turn.inputMessageId, options
358
- );
359
- let completion = await waitForCompletion();
360
- // Work around https://github.com/anomalyco/opencode/issues/37073. Some
361
- // reasoning models finish successfully without emitting a text part.
362
- if (
363
- !turn.interruptRequested &&
364
- !completion.result?.error &&
365
- !text(completion.result?.text)
366
- ) {
356
+ const waitForCompletion = async () => {
357
+ for (;;) {
358
+ const completion = await waitForOpenCodeMessages(
359
+ client, conversationId, () => turn.inputMessageId, options
360
+ );
361
+ // Keep native polling/progress live while an admitted input's local
362
+ // commit is pending, but do not accept its terminal result yet.
363
+ const admission = turn.admission;
364
+ await admission?.promise;
365
+ options.signal?.throwIfAborted();
366
+ if (admission !== turn.admission || completion.inputMessageId !== text(turn.inputMessageId)) {
367
+ continue;
368
+ }
369
+ return completion;
370
+ }
371
+ };
372
+ for (;;) {
373
+ const completion = await waitForCompletion();
374
+ // Work around https://github.com/anomalyco/opencode/issues/37073. Some
375
+ // reasoning models complete successfully without a user-facing text part.
376
+ if (turn.interruptRequested || completion.result?.error || text(completion.result?.text)) return completion;
367
377
  await beforeRecovery?.(completion);
378
+ const admission = turn.admission;
379
+ await admission?.promise;
380
+ options.signal?.throwIfAborted();
381
+ if (admission !== turn.admission || completion.inputMessageId !== text(turn.inputMessageId)) {
382
+ continue;
383
+ }
368
384
  const admitted = await client.prompt(conversationId, {
369
385
  agent,
370
386
  delivery: "queue",
@@ -375,11 +391,12 @@ export async function waitForOpenCodeFinalResponse(client, conversationId, turn,
375
391
  },
376
392
  resume: true
377
393
  }, { signal: options.signal });
378
- turn.inputMessageId = text(admitted?.id) || recoveryMessageId;
379
- turn.updatedAt = new Date().toISOString();
380
- completion = await waitForCompletion();
394
+ if (admission === turn.admission && completion.inputMessageId === text(turn.inputMessageId)) {
395
+ turn.inputMessageId = text(admitted?.id) || recoveryMessageId;
396
+ turn.updatedAt = new Date().toISOString();
397
+ }
398
+ return waitForCompletion();
381
399
  }
382
- return completion;
383
400
  }
384
401
 
385
402
  // Production detached-turn lifetime. Applications project the completed native
@@ -69,6 +69,7 @@ export function createApiConversationDriver({ connections, apiClientFactory, fet
69
69
  const connection = await connections.resolve({ context, integrationId: configuration.integrationId });
70
70
  validateConnectionModel(configuration, connection);
71
71
  client = createAiConnectionClient(connection, { fetch, effort: configuration.effort,
72
+ reportUnavailableToolCalls: Boolean(tools),
72
73
  maxOutputTokens: limits.maxOutputTokens, timeoutMs: limits.timeoutMs });
73
74
  }
74
75
  signal.throwIfAborted();
@@ -271,6 +271,19 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
271
271
  async createConversation({ native, input, context }) {
272
272
  return native.owner.createConversation(input, { context, acquire: native.owner.acquire });
273
273
  },
274
+ async runDetachedConversation({ native, input, context }) {
275
+ const conversationId = input.conversationId || input.threadId || randomUUID();
276
+ const created = !input.conversationId && !input.threadId;
277
+ const { owner } = native;
278
+ const entry = await owner.acquire(context, conversationId, { create: created });
279
+ const executionProfile = native.executionProfile;
280
+ if (created && context.assistantScope) entry.profile = executionProfile;
281
+ if (executionProfile) await context.onEvent?.({ type: "execution-profile", executionProfile });
282
+ const request = { ...input, conversationId };
283
+ await owner.startTurn(await owner.acquire(context, conversationId, { operation: "start", input: request }), request);
284
+ const result = await owner.wait(await owner.acquire(context, conversationId), request, { context, acquire: owner.acquire });
285
+ return executionProfile ? { ...result, executionProfile } : result;
286
+ },
274
287
  async createBinding() {
275
288
  return { conversationId: randomUUID(), workdir: await realpath(workdir), configRoot, executionId: "", sent: false };
276
289
  },
@@ -41,8 +41,13 @@ export function createCodexConversationAdapter(host) {
41
41
  const previous = tasks.get(threadId) || Promise.resolve();
42
42
  const task = previous.catch(() => null).then(() => host.runRequest(async () => {
43
43
  const client = await host.client();
44
- params = await prepareInstructions(params, threadId);
45
44
  const generation = contextGeneration;
45
+ const bound = bindings.get(threadId);
46
+ const requestedParams = params;
47
+ const retainInstalledInstructions = bound?.params.developerInstructions != null && !params.ephemeral && !bound.params.ephemeral;
48
+ params = await prepareInstructions(retainInstalledInstructions
49
+ ? { ...params, systemPrompt: undefined, developerInstructions: bound.params.developerInstructions }
50
+ : params, threadId);
46
51
  const signal = AbortSignal.timeout(host.timeoutMs || 10_000);
47
52
  const assertRecoveryOpen = () => {
48
53
  signal.throwIfAborted();
@@ -54,7 +59,6 @@ export function createCodexConversationAdapter(host) {
54
59
  assertRecoveryOpen();
55
60
  return client.request(method, input, { signal });
56
61
  };
57
- const bound = bindings.get(threadId);
58
62
  const fixedPolicy = params.config?.shell_environment_policy ||
59
63
  (bound?.managed === false ? bound.params.config.shell_environment_policy : null);
60
64
  if (fixedPolicy && (params.developerInstructions == null || params.ephemeral || bound?.params.ephemeral)) {
@@ -63,7 +67,7 @@ export function createCodexConversationAdapter(host) {
63
67
  }
64
68
  return operation(threadParameters({ ...bound?.params, ...params }, {}));
65
69
  }
66
- const environment = instructionEnvironment(params, fixedPolicy?.set || await host.prepareEnvironment(
70
+ let environment = instructionEnvironment(params, fixedPolicy?.set || await host.prepareEnvironment(
67
71
  bound?.params?.config?.shell_environment_policy?.set || host.environment || {}
68
72
  ));
69
73
  const previousConfig = { ...bound?.params?.config };
@@ -94,7 +98,7 @@ export function createCodexConversationAdapter(host) {
94
98
  nativeHistoryPath = thread?.path || "";
95
99
  return typeof thread?.status === "string" ? thread.status : thread?.status?.type;
96
100
  };
97
- const nativeStatus = await readStatus();
101
+ let nativeStatus = await readStatus();
98
102
  if (!bound && !requestParams.modelProvider && nativeModelProvider) {
99
103
  // A restored observer has no saved request parameters. Recover the
100
104
  // native thread's provider configuration before resuming its controls.
@@ -122,14 +126,29 @@ export function createCodexConversationAdapter(host) {
122
126
  processChanged: Boolean(bound?.executionId)
123
127
  }) || requestParams;
124
128
  }
125
- const bindingMatches = !providerChanged && bound?.client === client &&
129
+ let bindingMatches = !providerChanged && bound?.client === client &&
126
130
  bound.params.developerInstructions === requestParams.developerInstructions &&
127
131
  bound.params.config.openai_base_url === requestParams.config.openai_base_url &&
128
132
  JSON.stringify(bound.params.config.model_providers) === JSON.stringify(requestParams.config.model_providers) &&
129
133
  Object.keys(requestParams.config).filter((key) => key.startsWith("model_providers.")).every((key) =>
130
134
  JSON.stringify(bound.params.config[key]) === JSON.stringify(requestParams.config[key])) &&
131
135
  JSON.stringify(bound.params.config.shell_environment_policy) === JSON.stringify(requestParams.config.shell_environment_policy);
132
- const instructionsChanged = requestParams.developerInstructions != null &&
136
+ const controlBindingMatches = bindingMatches;
137
+ const installedRequestParams = requestParams;
138
+ if (retainInstalledInstructions && (!bindingMatches || nativeStatus !== "active")) {
139
+ // An active healthy turn keeps its installed prompt. Real control
140
+ // recovery and the next idle admission still read current instructions.
141
+ params = await prepareInstructions(requestedParams, threadId);
142
+ environment = instructionEnvironment(params, environment);
143
+ requestParams = { ...requestParams,
144
+ ...(Object.hasOwn(params, "developerInstructions") ? { developerInstructions: params.developerInstructions } : {}),
145
+ config: { ...requestParams.config, shell_environment_policy: { ...requestParams.config.shell_environment_policy, set: environment } }
146
+ };
147
+ bindingMatches = bindingMatches && bound.params.developerInstructions === requestParams.developerInstructions &&
148
+ JSON.stringify(bound.params.config.shell_environment_policy) === JSON.stringify(requestParams.config.shell_environment_policy);
149
+ }
150
+ const instructionOnly = retainInstalledInstructions && controlBindingMatches && !bindingMatches;
151
+ let instructionsChanged = requestParams.developerInstructions != null &&
133
152
  instructionPrompts.get(threadId) !== requestParams.developerInstructions;
134
153
  let pausedGoal = null;
135
154
  let resumed = null;
@@ -138,6 +157,16 @@ export function createCodexConversationAdapter(host) {
138
157
  goal.createdAt === original.createdAt && goal.objective === original.objective;
139
158
  const log = (event, fields = {}) => host.log?.(event, fields, { threadId, environment });
140
159
  try {
160
+ if (instructionOnly) {
161
+ // A native goal can start work while the host reads its prompt.
162
+ nativeStatus = await readStatus();
163
+ if (nativeStatus === "active") {
164
+ requestParams = installedRequestParams;
165
+ environment = requestParams.config.shell_environment_policy.set;
166
+ bindingMatches = true;
167
+ instructionsChanged = false;
168
+ }
169
+ }
141
170
  if (!bindingMatches || nativeStatus === "notLoaded") {
142
171
  log("reload_started", { reason: bound ? "control-environment-changed" : "unverified-thread-binding", nativeStatus });
143
172
  if (nativeStatus !== "notLoaded") {
@@ -150,57 +179,71 @@ export function createCodexConversationAdapter(host) {
150
179
  }
151
180
  }
152
181
  if (await readStatus() !== "idle") {
153
- const page = await request("thread/turns/list", { threadId, limit: 1, sortDirection: "desc", itemsView: "summary" });
154
- const turnId = page.data?.[0]?.id;
155
- if (!turnId) throw new Error("Codex's running turn could not be identified for control recovery.");
156
- await request("turn/interrupt", { threadId, turnId });
157
- while (await readStatus() !== "idle") {
158
- signal.throwIfAborted();
159
- await new Promise((resolve) => setTimeout(resolve, 25));
182
+ if (instructionOnly) {
183
+ // Never interrupt work solely to refresh its instructions.
184
+ requestParams = installedRequestParams;
185
+ environment = requestParams.config.shell_environment_policy.set;
186
+ bindingMatches = true;
187
+ instructionsChanged = false;
188
+ } else {
189
+ const page = await request("thread/turns/list", { threadId, limit: 1, sortDirection: "desc", itemsView: "summary" });
190
+ const turnId = page.data?.[0]?.id;
191
+ if (!turnId) throw new Error("Codex's running turn could not be identified for control recovery.");
192
+ assertRecoveryOpen();
193
+ host.recordInterruption?.(threadId, turnId);
194
+ await request("turn/interrupt", { threadId, turnId });
195
+ while (await readStatus() !== "idle") {
196
+ signal.throwIfAborted();
197
+ await new Promise((resolve) => setTimeout(resolve, 25));
198
+ }
160
199
  }
161
200
  }
162
201
  if (pausedGoal) {
163
202
  const { goal } = await request("thread/goal/get", { threadId });
164
203
  pausedGoal = isSamePausedGoal(goal, pausedGoal) ? goal : null;
165
204
  }
166
- const detached = await request("thread/unsubscribe", { threadId });
167
- if (!["unsubscribed", "notSubscribed", "notLoaded"].includes(detached?.status)) {
168
- throw new Error("Codex did not confirm detachment before control recovery.");
205
+ if (!bindingMatches) {
206
+ const detached = await request("thread/unsubscribe", { threadId });
207
+ if (!["unsubscribed", "notSubscribed", "notLoaded"].includes(detached?.status)) {
208
+ throw new Error("Codex did not confirm detachment before control recovery.");
209
+ }
169
210
  }
170
211
  }
171
- bindings.delete(threadId);
172
- await host.beforeResume?.(threadId);
173
- resumed = await request("thread/resume", { excludeTurns: true, ...requestParams, threadId });
174
- if (requestParams.modelProvider && resumed.modelProvider !== requestParams.modelProvider) {
175
- throw Object.assign(new Error("Another native subscriber retained the previous model provider. Close the native assistant terminal and retry."), {
176
- code: "assistant_codex_provider_binding_retained"
177
- });
178
- }
179
- if (!["idle", "active"].includes(await readStatus())) throw new Error("Codex did not confirm the thread loaded.");
180
- if (nativeStatus !== "notLoaded") {
181
- await host.verifyEnvironment(threadId, environment, request, signal, {
182
- requiredKeys: requestParams.developerInstructions == null ? [] : [INSTRUCTION_REVISION_ENV]
183
- });
184
- }
185
- const checked = fixedPolicy ? environment : instructionEnvironment(params, await host.prepareEnvironment(environment));
186
- if (JSON.stringify(checked) !== JSON.stringify(environment)) {
187
- throw new Error("Managed controls changed again during thread recovery.");
188
- }
189
- if (instructionsChanged) {
190
- // Resume config supplies the next compacted context, but existing
191
- // history retains its original developer message. A revision in
192
- // the developer lane makes this change effective before new work.
193
- // An unknown binding also needs restoration; ordinary turns and
194
- // socket reconnects with an acknowledged binding do not repeat it.
195
- await request("thread/inject_items", { threadId, items: [{ type: "message", role: "developer", content: [{
196
- type: "input_text", text: "These are the current application instructions. They replace earlier application instructions; other instructions remain in effect.\n\n" + requestParams.developerInstructions
197
- }] }] });
212
+ if (!bindingMatches || nativeStatus === "notLoaded") {
213
+ bindings.delete(threadId);
214
+ await host.beforeResume?.(threadId);
215
+ resumed = await request("thread/resume", { excludeTurns: true, ...requestParams, threadId });
216
+ if (requestParams.modelProvider && resumed.modelProvider !== requestParams.modelProvider) {
217
+ throw Object.assign(new Error("Another native subscriber retained the previous model provider. Close the native assistant terminal and retry."), {
218
+ code: "assistant_codex_provider_binding_retained"
219
+ });
220
+ }
221
+ if (!["idle", "active"].includes(await readStatus())) throw new Error("Codex did not confirm the thread loaded.");
222
+ if (nativeStatus !== "notLoaded") {
223
+ await host.verifyEnvironment(threadId, environment, request, signal, {
224
+ requiredKeys: requestParams.developerInstructions == null ? [] : [INSTRUCTION_REVISION_ENV]
225
+ });
226
+ }
227
+ const checked = fixedPolicy ? environment : instructionEnvironment(params, await host.prepareEnvironment(environment));
228
+ if (JSON.stringify(checked) !== JSON.stringify(environment)) {
229
+ throw new Error("Managed controls changed again during thread recovery.");
230
+ }
231
+ if (instructionsChanged) {
232
+ // Resume config supplies the next compacted context, but existing
233
+ // history retains its original developer message. A revision in
234
+ // the developer lane makes this change effective before new work.
235
+ // An unknown binding also needs restoration; ordinary turns and
236
+ // socket reconnects with an acknowledged binding do not repeat it.
237
+ await request("thread/inject_items", { threadId, items: [{ type: "message", role: "developer", content: [{
238
+ type: "input_text", text: "These are the current application instructions. They replace earlier application instructions; other instructions remain in effect.\n\n" + requestParams.developerInstructions
239
+ }] }] });
240
+ assertRecoveryOpen();
241
+ instructionPrompts.set(threadId, requestParams.developerInstructions);
242
+ }
198
243
  assertRecoveryOpen();
199
- instructionPrompts.set(threadId, requestParams.developerInstructions);
244
+ bindings.set(threadId, { client, executionId, params: requestParams, managed: !fixedPolicy });
245
+ log("ready");
200
246
  }
201
- assertRecoveryOpen();
202
- bindings.set(threadId, { client, executionId, params: requestParams, managed: !fixedPolicy });
203
- log("ready");
204
247
  }
205
248
  } catch (cause) {
206
249
  host.onRecoveryFailure?.(threadId);
@@ -214,6 +214,9 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
214
214
  async createConversation({ sessionId, native, input, context }) {
215
215
  return native.runOwner.createConversation(sessionId, input, context);
216
216
  },
217
+ async runDetachedConversation({ sessionId, native, input, options }) {
218
+ return native.runOwner.runDetachedConversation(sessionId, input, options);
219
+ },
217
220
  async createBinding() {
218
221
  return { threadId: "", workdir: await realpath(workdir), configRoot, executionId: "" };
219
222
  },
@@ -452,7 +455,7 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
452
455
  },
453
456
  complete: completeObservation
454
457
  }
455
- }));
458
+ }), providerKey, providerOptions);
456
459
  }
457
460
 
458
461
  async function stop() {
@@ -109,6 +109,9 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
109
109
  async createConversation({ native, input, context }) {
110
110
  return native.owner.createPreparedConversation(await native.preparation.creation(input, context));
111
111
  },
112
+ async runDetachedConversation({ native, input, options }) {
113
+ return native.owner.runPreparedConversationTurn(await native.preparation.turn(input, options));
114
+ },
112
115
  async createBinding() {
113
116
  const directory = path.join(stateDirectory, randomUUID());
114
117
  await mkdir(directory, { recursive: true, mode: 0o700 });
@@ -1497,11 +1497,12 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1497
1497
  return entry.conversation.commands.select(input, context);
1498
1498
  }
1499
1499
  if (entry.conversation?.native?.scoped) throw failure("This scoped conversation retains its original selected profile.", "conversation_unsupported");
1500
- const keys = selection ? ["operationId", "expectedSegmentId", "engine", "configuration"]
1500
+ const keys = selection ? ["operationId", "expectedSegmentId", "engine", "configuration", "retireNative"]
1501
1501
  : ["operationId", "expectedSegmentId", "reason", "engine", "configuration", "briefing"];
1502
1502
  if (!input || !/^[\w-]{1,128}$/u.test(input.operationId || "") || typeof input.expectedSegmentId !== "string" ||
1503
1503
  !input.expectedSegmentId || (selection ? typeof input.engine !== "string" : !["engine-change", "model-change", "renewal"].includes(input.reason)) ||
1504
1504
  input.briefing !== undefined && typeof input.briefing !== "string" ||
1505
+ input.retireNative !== undefined && typeof input.retireNative !== "boolean" ||
1505
1506
  Object.keys(input).some(key => !keys.includes(key))) {
1506
1507
  throw failure("Replacement needs an operationId, exact predecessor segment, and an engine-change, model-change or renewal reason.", "conversation_invalid_replacement", 400);
1507
1508
  }
@@ -1569,7 +1570,9 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1569
1570
  const predecessorId = nativeIdentity(state);
1570
1571
  const nativeReplacement = !selection && state.engine !== "api" && predecessorId &&
1571
1572
  (state.engine !== "claude" || state.binding.sent);
1572
- if (selection && engine !== "api" && engine === state.engine && !pending) {
1573
+ const retainNativeBinding = input.retireNative !== true && engine === state.engine &&
1574
+ (engine !== "opencode" || configuration.integrationId === state.configuration.integrationId);
1575
+ if (selection && engine !== "api" && retainNativeBinding && !pending) {
1573
1576
  // The production native owners apply compatible settings to the running
1574
1577
  // conversation themselves. Selecting a model is not process retirement.
1575
1578
  await storage.write(entry.id, async transaction => {
@@ -1587,8 +1590,14 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1587
1590
  }
1588
1591
  if (!pending) {
1589
1592
  const history = (await readTranscript(entry)).filter(turn => !turn.metadata?.runtime?.supersededBy);
1590
- const destination = selection ? state.engine === engine ? state
1591
- : driver.createBinding ? retainedSegments(state).findLast(segment => segment.engine === engine) : null : null;
1593
+ let destination = null;
1594
+ if (selection) {
1595
+ if (retainNativeBinding) destination = state;
1596
+ else if (input.retireNative !== true && engine !== state.engine && driver.createBinding) {
1597
+ destination = retainedSegments(state).findLast(segment => segment.engine === engine &&
1598
+ (engine !== "opencode" || segment.configuration.integrationId === configuration.integrationId));
1599
+ }
1600
+ }
1592
1601
  if (engine !== "api" && (input.briefing || "").length > maximumContinuity) throw new Error("The continuity briefing exceeds the configured history budget.");
1593
1602
  const continuity = destination ? { text: destination.continuity, attachments: destination.continuityAttachments || [] }
1594
1603
  : engine === "api" ? conversationContinuity({ history, briefing: input.briefing, maximumCharacters: maximumContinuity })
@@ -1660,7 +1669,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1660
1669
  if (value) {
1661
1670
  if (engine !== "api") state.seen = value.engines[engine].seen;
1662
1671
  state.lastEngine = value.lastEngine;
1663
- } else if (!selection && engine !== "api") state.lastEngine = "";
1672
+ } else if (engine !== "api" &&
1673
+ (!selection || input.retireNative === true || engine === "opencode" && !pending.binding.sessionId)) {
1674
+ state.lastEngine = "";
1675
+ }
1664
1676
  delete state.replacement;
1665
1677
  await transaction.writeMetadata(metadata);
1666
1678
  }
@@ -1881,6 +1893,21 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
1881
1893
  }
1882
1894
  return createDriver(prepared.engine, defaultHost).createConversation(prepared);
1883
1895
  },
1896
+ async runNativeDetachedConversation({ id, context, input = {}, options } = {}) {
1897
+ if (typeof id !== "string" || !id.trim()) throw new TypeError("A parent conversation or scope id is required.");
1898
+ await access(context, id, "runDetachedConversation");
1899
+ if (typeof defaultHost?.conversation !== "function") {
1900
+ throw new TypeError("Native detached execution requires a configured conversation host.");
1901
+ }
1902
+ const prepared = await defaultHost.conversation({ id, context, input,
1903
+ ...(options === undefined ? {} : { options }), operation: "runDetachedConversation" });
1904
+ if (closed) throw failure("This conversation runtime is closed.", "conversation_closed");
1905
+ if (prepared?.sessionId !== id || !["codex", "claude", "opencode"].includes(prepared.engine) || !prepared.native ||
1906
+ !Object.hasOwn(prepared, "input") || !Object.hasOwn(prepared, "context") || !Object.hasOwn(prepared, "options")) {
1907
+ throw new TypeError("Native detached execution requires its authorized host preparation.");
1908
+ }
1909
+ return createDriver(prepared.engine, defaultHost).runDetachedConversation(prepared);
1910
+ },
1884
1911
  async open({ id, context, configuration, engine, host, representation = "canonical" } = {}) {
1885
1912
  if (typeof id !== "string" || !id.trim()) throw new TypeError("A conversation id is required.");
1886
1913
  await access(context, id, "open");
@@ -1,4 +1,19 @@
1
1
  export {
2
+ classifyCodexAppServerEvent,
3
+ codexAppServerAssistantItemText,
4
+ codexAppServerContentText,
5
+ codexAppServerErrorText,
6
+ codexAppServerNotificationError,
7
+ codexAppServerNotificationEventPayload,
8
+ codexAppServerNotificationEventType,
9
+ codexAppServerNotificationItem,
10
+ codexAppServerNotificationParams,
11
+ codexAppServerNotificationThreadId,
12
+ codexAppServerNotificationTurnStatus,
13
+ codexAppServerNotificationUsageLimitExceeded,
14
+ codexAppServerOutputOwnerTurnId,
15
+ codexAppServerStatusFromValue,
16
+ codexAppServerUserMessageText,
2
17
  codexContextUsageFromNotification,
3
18
  codexAppServerContextRefreshReason,
4
19
  codexAppServerNotificationEvent,
@@ -1,4 +1,9 @@
1
1
  export {
2
+ codexAppServerTurnStatusIsActive,
3
+ codexAppServerTurnStatusIsComplete,
4
+ codexAppServerTurnStatusIsProviderFailure,
5
+ codexAppServerTurnStatusIsSuccessfulComplete,
6
+ createCodexAppServerDetachedTurnWatcher,
2
7
  codexAppServerAgentRun,
3
8
  codexAppServerConversationTurnIsActive,
4
9
  codexAppServerFrozenTurnInterruptResponse,
@@ -1,3 +1,7 @@
1
1
  export {
2
+ OPENCODE_RESPONSE_LIMIT_BYTES,
3
+ createOpenCodeServerClient,
4
+ openCodeAssistantMessageText,
5
+ readBoundedResponse,
2
6
  retireOpenCodeConversationHistory
3
7
  } from "../conversation/openCodeClient.js";
@@ -1,4 +1,4 @@
1
- import { generateText, jsonSchema, streamText } from "ai";
1
+ import { generateText, jsonSchema, NoSuchToolError, streamText } from "ai";
2
2
  import { createOpenAI } from "@ai-sdk/openai";
3
3
  import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
4
4
  import { createAnthropic } from "@ai-sdk/anthropic";
@@ -47,7 +47,7 @@ function sdkMessages(messages) {
47
47
  }
48
48
 
49
49
  /** Accept only the server-side result of an authorized AI connection resolver. */
50
- export function createAiConnectionClient(connection, { fetch, timeoutMs = 120_000, maxOutputTokens, effort } = {}) {
50
+ export function createAiConnectionClient(connection, { fetch, timeoutMs = 120_000, maxOutputTokens, effort, reportUnavailableToolCalls = false } = {}) {
51
51
  if (!connection?.apiKey || !connection.model || !Object.hasOwn(providers, connection.sdkPackage)) {
52
52
  throw new TypeError("An authorized AI connection with a supported SDK and model is required.");
53
53
  }
@@ -75,23 +75,21 @@ export function createAiConnectionClient(connection, { fetch, timeoutMs = 120_00
75
75
  async *createChatCompletionStream(input) {
76
76
  const result = streamText({ ...options(input), onError() {} });
77
77
  let toolIndex = 0;
78
- let failure;
79
78
  for await (const part of result.fullStream) {
80
- // Let the SDK settle its stream before reporting a rejected tool call.
81
- // Cancelling this iterator mid-step leaves its completion unresolved.
82
- if (part.type === "error" || (part.type === "tool-call" && part.invalid)) {
83
- failure ||= part.error || new Error("The model returned an invalid tool call.");
84
- }
85
- if (part.type === "abort") failure ||= input?.signal?.reason || new Error("Assistant request was aborted.");
86
- if (failure) continue;
79
+ if (part.type === "error") throw part.error;
80
+ if (part.type === "abort") throw input?.signal?.reason || new Error("Assistant request was aborted.");
87
81
  if (part.type === "text-delta") yield { choices: [{ delta: { content: part.text } }] };
88
82
  if (part.type === "finish") yield { choices: [{ delta: {}, finish_reason: part.finishReason }], usage: part.totalUsage };
89
83
  if (part.type === "tool-call") {
84
+ // Only the application catalogue can reject an unavailable action
85
+ // and persist its no-effect receipt. Other invalid calls stay errors.
86
+ const unavailable = reportUnavailableToolCalls === true && NoSuchToolError.isInstance(part.error) &&
87
+ !part.providerExecuted && part.input !== null && typeof part.input === "object" && !Array.isArray(part.input);
88
+ if (part.invalid && !unavailable) throw part.error;
90
89
  yield { choices: [{ delta: { tool_calls: [{ index: toolIndex++, id: part.toolCallId,
91
90
  function: { name: part.toolName, arguments: JSON.stringify(part.input) } }] } }] };
92
91
  }
93
92
  }
94
- if (failure) throw failure;
95
93
  }
96
94
  });
97
95
  }