@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.
- package/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/App.vue +12 -2
- package/fixtures/responsive-assistant/ConversationFixture.vue +79 -2
- package/package.json +19 -9
- package/src/client/conversation/AssistantConversationElement.vue +107 -41
- package/src/client/conversation/AssistantTranscript.vue +192 -18
- package/src/server/conversation/claudeClient.js +2 -1
- package/src/server/conversation/claudeTurn.js +16 -7
- package/src/server/conversation/codexConversations.js +94 -0
- package/src/server/conversation/codexProvider.js +15 -5
- package/src/server/conversation/codexProviderOwner.js +70 -47
- package/src/server/conversation/codexProviderSelection.js +5 -2
- package/src/server/conversation/codexRuntimeLifecycle.js +2 -1
- package/src/server/conversation/codexTurn.js +10 -1
- package/src/server/conversation/codexTurnControl.js +1 -1
- package/src/server/conversation/codexTurnRecovery.js +21 -8
- package/src/server/conversation/openCodeRuntime.js +107 -64
- package/src/server/conversation/openCodeTurn.js +33 -16
- package/src/server/conversation/providers/api.js +1 -0
- package/src/server/conversation/providers/claudeDriver.js +13 -0
- package/src/server/conversation/providers/codex.js +90 -47
- package/src/server/conversation/providers/codexDriver.js +4 -1
- package/src/server/conversation/providers/opencodeDriver.js +3 -0
- package/src/server/conversation/runtime.js +32 -5
- package/src/server/hosts/codexEvents.js +15 -0
- package/src/server/hosts/codexTurn.js +5 -0
- package/src/server/hosts/openCodeClient.js +4 -0
- package/src/server/lib/aiConnectionClient.js +9 -11
- package/test/aiConnectionClient.test.js +209 -0
- package/test/assistantScroll.browser.test.js +437 -9
- package/test/claudeClient.test.js +41 -0
- package/test/claudeConversationSession.test.js +78 -0
- package/test/claudeTurn.test.js +18 -0
- package/test/codexConversationSession.test.js +318 -0
- package/test/codexTurn.test.js +203 -2
- package/test/conversationConfiguration.test.js +27 -0
- package/test/conversationReplacement.test.js +76 -1
- package/test/conversationRuntime.test.js +173 -4
- package/test/conversationSession.test.js +139 -0
- package/test/conversationTools.test.js +17 -3
- package/test/opencodeConversationSession.test.js +200 -1
- package/test/packageMetadata.test.js +75 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1591
|
-
|
|
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 (
|
|
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,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
|
-
|
|
81
|
-
|
|
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
|
+
});
|