@jskit-ai/assistant-core 0.1.204 → 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 (43) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/App.vue +12 -2
  3. package/fixtures/responsive-assistant/ConversationFixture.vue +79 -2
  4. package/package.json +19 -9
  5. package/src/client/conversation/AssistantConversationElement.vue +107 -41
  6. package/src/client/conversation/AssistantTranscript.vue +192 -18
  7. package/src/server/conversation/claudeClient.js +2 -1
  8. package/src/server/conversation/claudeTurn.js +16 -7
  9. package/src/server/conversation/codexConversations.js +94 -0
  10. package/src/server/conversation/codexProvider.js +15 -5
  11. package/src/server/conversation/codexProviderOwner.js +70 -47
  12. package/src/server/conversation/codexProviderSelection.js +5 -2
  13. package/src/server/conversation/codexRuntimeLifecycle.js +2 -1
  14. package/src/server/conversation/codexTurn.js +10 -1
  15. package/src/server/conversation/codexTurnControl.js +1 -1
  16. package/src/server/conversation/codexTurnRecovery.js +21 -8
  17. package/src/server/conversation/openCodeRuntime.js +107 -64
  18. package/src/server/conversation/openCodeTurn.js +33 -16
  19. package/src/server/conversation/providers/api.js +1 -0
  20. package/src/server/conversation/providers/claudeDriver.js +13 -0
  21. package/src/server/conversation/providers/codex.js +90 -47
  22. package/src/server/conversation/providers/codexDriver.js +4 -1
  23. package/src/server/conversation/providers/opencodeDriver.js +3 -0
  24. package/src/server/conversation/runtime.js +32 -5
  25. package/src/server/hosts/codexEvents.js +15 -0
  26. package/src/server/hosts/codexTurn.js +5 -0
  27. package/src/server/hosts/openCodeClient.js +4 -0
  28. package/src/server/lib/aiConnectionClient.js +9 -11
  29. package/test/aiConnectionClient.test.js +209 -0
  30. package/test/assistantScroll.browser.test.js +437 -9
  31. package/test/claudeClient.test.js +41 -0
  32. package/test/claudeConversationSession.test.js +78 -0
  33. package/test/claudeTurn.test.js +18 -0
  34. package/test/codexConversationSession.test.js +318 -0
  35. package/test/codexTurn.test.js +203 -2
  36. package/test/conversationConfiguration.test.js +27 -0
  37. package/test/conversationReplacement.test.js +76 -1
  38. package/test/conversationRuntime.test.js +173 -4
  39. package/test/conversationSession.test.js +139 -0
  40. package/test/conversationTools.test.js +17 -3
  41. package/test/opencodeConversationSession.test.js +200 -1
  42. package/test/packageMetadata.test.js +75 -0
  43. package/test/serviceToolCatalog.test.js +31 -0
@@ -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
  }
@@ -159,3 +159,212 @@ test("authorized image bytes use the selected provider protocol", async () => {
159
159
  ] }] }));
160
160
  assert.equal(chunks[0].choices[0].delta.content, "An image");
161
161
  });
162
+
163
+ test("SDK streaming rejects its first provider failure before EOF without exposing later output", { timeout: 5000 }, async t => {
164
+ const source = new TransformStream();
165
+ const writer = source.writable.getWriter();
166
+ t.after(() => writer.abort().catch(() => {}));
167
+ let requests = 0;
168
+ const client = createAiConnectionClient(await publicConnection(), {
169
+ async fetch(url, options) {
170
+ requests++;
171
+ assert.equal(String(url), "https://opencode.ai/zen/v1/chat/completions");
172
+ assert.equal(new Headers(options.headers).get("authorization"), "Bearer public");
173
+ assert.equal(JSON.parse(options.body).tools[0].function.name, "lookup");
174
+ return new Response(source.readable, { headers: { "content-type": "text/event-stream" } });
175
+ }
176
+ });
177
+ const tools = [{ type: "function", function: { name: "lookup", parameters: {
178
+ type: "object", properties: { query: { type: "string" } }, required: ["query"]
179
+ } } }];
180
+ const iterator = client.createChatCompletionStream({ messages: [{ role: "user", content: "Find records" }], tools });
181
+ const send = data => writer.write(new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`));
182
+ const first = iterator.next();
183
+ await send({ choices: [{ index: 0, delta: { content: "Before failure" } }] });
184
+ assert.deepEqual((await first).value, { choices: [{ delta: { content: "Before failure" } }] });
185
+ let settled = false;
186
+ const ending = iterator.next().then(
187
+ result => { settled = true; return { result }; },
188
+ error => { settled = true; return { error }; }
189
+ );
190
+ const firstFailure = { message: "First provider failure", code: "FIRST_FAILURE" };
191
+ await send({ error: firstFailure });
192
+ await new Promise(resolve => setImmediate(resolve));
193
+ assert.equal(settled, true, "the original provider failure rejects before any late output or transport EOF");
194
+ await send({ choices: [{ index: 0, delta: { content: "Must not be exposed" } }] });
195
+ await send({ choices: [{ index: 0, delta: { tool_calls: [
196
+ { index: 0, id: "late_call", type: "function", function: { name: "lookup", arguments: '{"query":"records"}' } }
197
+ ] } }] });
198
+ await send({ error: { message: "Later provider failure", code: "LATER_FAILURE" } });
199
+ await send({ choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
200
+ usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 } });
201
+ await new Promise(resolve => setImmediate(resolve));
202
+ assert.equal(settled, true, "the original provider failure rejects before transport EOF");
203
+ await writer.close();
204
+ const outcome = await ending;
205
+ assert.equal(outcome.error?.name, "AI_StreamProviderError");
206
+ assert.equal(outcome.error.message, firstFailure.message);
207
+ assert.equal(outcome.error.code, firstFailure.code);
208
+ assert.deepEqual(outcome.error.data, firstFailure, "a later provider error must not replace the first failure");
209
+ assert.equal(outcome.result, undefined, "late text, tool calls, finish and usage never become a yielded chunk");
210
+ assert.equal((await iterator.next()).done, true);
211
+ assert.equal(requests, 1, "draining a failed stream must not retry or start a tool round");
212
+ });
213
+
214
+ test("SDK streaming rejects the first invalid tool input without yielding later calls or completion", { timeout: 5000 }, async t => {
215
+ const source = new TransformStream();
216
+ const writer = source.writable.getWriter();
217
+ t.after(() => writer.abort().catch(() => {}));
218
+ let requests = 0;
219
+ const client = createAiConnectionClient(await publicConnection(), {
220
+ async fetch() {
221
+ requests++;
222
+ return new Response(source.readable, { headers: { "content-type": "text/event-stream" } });
223
+ }
224
+ });
225
+ const tools = [{ type: "function", function: { name: "lookup", parameters: {
226
+ type: "object", properties: { query: { type: "string" } }, required: ["query"]
227
+ } } }];
228
+ const iterator = client.createChatCompletionStream({ messages: [{ role: "user", content: "Find records" }], tools });
229
+ const send = data => writer.write(new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`));
230
+ const first = iterator.next();
231
+ await send({ choices: [{ index: 0, delta: { content: "Before tool validation" } }] });
232
+ assert.equal((await first).value.choices[0].delta.content, "Before tool validation");
233
+ const ending = iterator.next().then(result => ({ result }), error => ({ error }));
234
+ // The compatible adapter flushes these calls at EOF; malformed JSON reaches
235
+ // the SDK's invalid-input branch rather than relying on schema validation.
236
+ await send({ choices: [{ index: 0, delta: { tool_calls: [
237
+ { index: 0, id: "invalid_first", type: "function", function: { name: "lookup", arguments: '{"query":}' } },
238
+ { index: 1, id: "invalid_later", type: "function", function: { name: "lookup", arguments: '{"later":}' } },
239
+ { index: 2, id: "valid_later", type: "function", function: { name: "lookup", arguments: '{"query":"records"}' } }
240
+ ] }, finish_reason: "tool_calls" }], usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 } });
241
+ await writer.close();
242
+ const outcome = await ending;
243
+ assert.equal(outcome.error?.name, "AI_InvalidToolInputError");
244
+ assert.equal(outcome.error.toolName, "lookup");
245
+ assert.equal(outcome.error.toolInput, '{"query":}', "the second invalid call cannot replace the first error");
246
+ assert.equal(outcome.result, undefined, "invalid and later valid calls, finish and usage remain unexposed");
247
+ assert.equal((await iterator.next()).done, true);
248
+ assert.equal(requests, 1);
249
+ });
250
+
251
+ test("SDK cancellation after its first text retains the original reason and suppresses later output", { timeout: 5000 }, async t => {
252
+ const source = new TransformStream();
253
+ const writer = source.writable.getWriter();
254
+ t.after(() => writer.abort().catch(() => {}));
255
+ const abort = new AbortController();
256
+ const reason = new Error("Stopped after the first text");
257
+ let requestSignal;
258
+ let requests = 0;
259
+ const client = createAiConnectionClient(await publicConnection(), {
260
+ async fetch(_url, options) {
261
+ requests++;
262
+ requestSignal = options.signal;
263
+ return new Response(source.readable, { headers: { "content-type": "text/event-stream" } });
264
+ }
265
+ });
266
+ const iterator = client.createChatCompletionStream({ messages: [{ role: "user", content: "Hello" }], signal: abort.signal });
267
+ const send = data => writer.write(new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`));
268
+ const first = iterator.next();
269
+ await send({ choices: [{ index: 0, delta: { content: "First text" } }] });
270
+ assert.equal((await first).value.choices[0].delta.content, "First text");
271
+ const ending = iterator.next().then(result => ({ result }), error => ({ error }));
272
+ abort.abort(reason);
273
+ assert.equal(requestSignal.aborted, true);
274
+ assert.equal(requestSignal.reason, reason);
275
+ // Fetch remains controlled: let the real SDK observe abort while consuming
276
+ // the response, then finish the transport instead of replacing its runner.
277
+ await send({ choices: [{ index: 0, delta: { content: "Late text" } }] });
278
+ await send({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
279
+ usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 } });
280
+ await writer.close();
281
+ const outcome = await ending;
282
+ assert.equal(outcome.error, reason);
283
+ assert.equal(outcome.result, undefined, "abort after text cannot publish later text, finish or usage");
284
+ assert.equal((await iterator.next()).done, true);
285
+ assert.equal(requests, 1);
286
+ });
287
+
288
+ test("SDK streaming retains its immediate provider failure when the later transport fails", { timeout: 5000 }, async t => {
289
+ const source = new TransformStream();
290
+ const writer = source.writable.getWriter();
291
+ t.after(() => writer.abort().catch(() => {}));
292
+ let requests = 0;
293
+ const client = createAiConnectionClient(await publicConnection(), {
294
+ async fetch() {
295
+ requests++;
296
+ return new Response(source.readable, { headers: { "content-type": "text/event-stream" } });
297
+ }
298
+ });
299
+ const iterator = client.createChatCompletionStream({ messages: [{ role: "user", content: "Hello" }] });
300
+ const send = data => writer.write(new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`));
301
+ const first = iterator.next();
302
+ await send({ choices: [{ index: 0, delta: { content: "Before failure" } }] });
303
+ assert.equal((await first).value.choices[0].delta.content, "Before failure");
304
+ let settled = false;
305
+ const ending = iterator.next().then(
306
+ result => { settled = true; return { result }; },
307
+ error => { settled = true; return { error }; }
308
+ );
309
+ const firstFailure = { message: "First provider failure", code: "FIRST_FAILURE" };
310
+ await send({ error: firstFailure });
311
+ await new Promise(resolve => setImmediate(resolve));
312
+ assert.equal(settled, true, "the first failure rejects before the later transport error");
313
+ await send({ choices: [{ index: 0, delta: { content: "Late text after rejection" } }] });
314
+ await new Promise(resolve => setImmediate(resolve));
315
+ assert.equal(settled, true, "the original provider failure rejects without waiting for later transport failure");
316
+ const lateTransportError = new Error("Late transport drain failure");
317
+ await writer.abort(lateTransportError);
318
+ const outcome = await ending;
319
+ assert.notEqual(outcome.error, lateTransportError, "transport drain failure must not replace the observed provider failure");
320
+ assert.equal(outcome.error?.name, "AI_StreamProviderError");
321
+ assert.equal(outcome.error.message, firstFailure.message);
322
+ assert.equal(outcome.error.code, firstFailure.code);
323
+ assert.deepEqual(outcome.error.data, firstFailure);
324
+ assert.equal(outcome.result, undefined, "late text and transport failure do not publish another chunk");
325
+ assert.equal((await iterator.next()).done, true);
326
+ assert.equal(requests, 1);
327
+ });
328
+
329
+
330
+ test("only explicit application-catalogue mode reports well-formed unavailable tool calls", async () => {
331
+ const toolCall = argumentsText => ({ choices: [{ index: 0, delta: { tool_calls: [
332
+ { index: 0, id: "unavailable-call", type: "function", function: { name: "unavailable_action", arguments: argumentsText } }
333
+ ] }, finish_reason: "tool_calls" }] });
334
+ const connection = await publicConnection();
335
+ for (const [reportUnavailableToolCalls, argumentsText] of [
336
+ [false, '{"value":"must not execute"}'], [true, '{"value":}'], [true, 'null'], [true, '[]'], [true, '"text"']
337
+ ]) {
338
+ let requests = 0;
339
+ const client = createAiConnectionClient(connection, { reportUnavailableToolCalls,
340
+ async fetch() { requests++; return eventsResponse([toolCall(argumentsText)]); }
341
+ });
342
+ await assert.rejects(Array.fromAsync(client.createChatCompletionStream({
343
+ messages: [{ role: "user", content: "Check without changing anything" }], tools: []
344
+ })), error => error.name === "AI_NoSuchToolError" && error.toolName === "unavailable_action");
345
+ assert.equal(requests, 1, "unavailable validation must not trigger an inference retry");
346
+ }
347
+ const invalidKnown = createAiConnectionClient(connection, { reportUnavailableToolCalls: true,
348
+ async fetch() { return eventsResponse([{ choices: [{ index: 0, delta: { tool_calls: [
349
+ { index: 0, id: "invalid-known", type: "function", function: { name: "lookup", arguments: '{"query":}' } }
350
+ ] }, finish_reason: "tool_calls" }] }]); }
351
+ });
352
+ await assert.rejects(Array.fromAsync(invalidKnown.createChatCompletionStream({
353
+ messages: [{ role: "user", content: "Find records" }],
354
+ tools: [{ type: "function", function: { name: "lookup", parameters: {
355
+ type: "object", properties: { query: { type: "string" } }, required: ["query"]
356
+ } } }]
357
+ })), error => error.name === "AI_InvalidToolInputError" && error.toolName === "lookup");
358
+ let requests = 0;
359
+ const client = createAiConnectionClient(connection, { reportUnavailableToolCalls: true,
360
+ async fetch() { requests++; return eventsResponse([toolCall('{"value":"must not execute"}')]); }
361
+ });
362
+ const chunks = await Array.fromAsync(client.createChatCompletionStream({
363
+ messages: [{ role: "user", content: "Check without changing anything" }], tools: []
364
+ }));
365
+ assert.deepEqual(chunks.flatMap(chunk => chunk.choices[0].delta.tool_calls || []), [{
366
+ index: 0, id: "unavailable-call", function: { name: "unavailable_action", arguments: '{"value":"must not execute"}' }
367
+ }]);
368
+ assert.equal(chunks.at(-1).choices[0].finish_reason, "tool-calls");
369
+ assert.equal(requests, 1, "reporting the attempted call neither executes an action nor starts another request");
370
+ });