@downcity/agent 1.1.332 → 1.1.335
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/bin/executor/core-engine/CoreEngineContextCompaction.d.ts.map +1 -1
- package/bin/executor/core-engine/CoreEngineContextCompaction.js +62 -6
- package/bin/executor/core-engine/CoreEngineContextCompaction.js.map +1 -1
- package/bin/executor/messages/SessionMessageCodec.d.ts.map +1 -1
- package/bin/executor/messages/SessionMessageCodec.js +63 -1
- package/bin/executor/messages/SessionMessageCodec.js.map +1 -1
- package/bin/session/messages/SessionAssistantMessageWriter.d.ts.map +1 -1
- package/bin/session/messages/SessionAssistantMessageWriter.js +10 -2
- package/bin/session/messages/SessionAssistantMessageWriter.js.map +1 -1
- package/bin/session/messages/SessionMessageCodec.d.ts.map +1 -1
- package/bin/session/messages/SessionMessageCodec.js +5 -3
- package/bin/session/messages/SessionMessageCodec.js.map +1 -1
- package/package.json +6 -6
- package/scripts/core-engine-context-compaction.test.mjs +15 -2
- package/scripts/image-plugin-job.test.mjs +128 -2
- package/scripts/session-messages.test.mjs +98 -0
- package/src/executor/core-engine/CoreEngineContextCompaction.ts +84 -15
- package/src/executor/messages/SessionMessageCodec.ts +69 -1
- package/src/session/messages/SessionAssistantMessageWriter.ts +14 -2
- package/src/session/messages/SessionMessageCodec.ts +7 -3
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -31,8 +31,28 @@ function create_image_message() {
|
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
function create_files(workspace_path) {
|
|
35
|
+
return {
|
|
36
|
+
root_path: workspace_path,
|
|
37
|
+
resolve_path: (...segments) => path.resolve(workspace_path, ...segments),
|
|
38
|
+
path_exists: async (file_path) => {
|
|
39
|
+
try {
|
|
40
|
+
await fs.access(file_path);
|
|
41
|
+
return true;
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
ensure_directory: (directory_path) => fs.mkdir(directory_path, { recursive: true }),
|
|
47
|
+
write_file_atomically: (file_path, content) => fs.writeFile(file_path, content),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
function create_context(workspace_path = process.cwd()) {
|
|
35
|
-
return {
|
|
52
|
+
return {
|
|
53
|
+
workspace_path,
|
|
54
|
+
files: create_files(workspace_path),
|
|
55
|
+
};
|
|
36
56
|
}
|
|
37
57
|
|
|
38
58
|
function create_registry(plugin, workspace_path = process.cwd()) {
|
|
@@ -185,13 +205,119 @@ test("ImagePlugin image_result returns final message when succeeded", async () =
|
|
|
185
205
|
assert.equal(result.success, true);
|
|
186
206
|
assert.equal(result.data.job_id, "img_1");
|
|
187
207
|
assert.equal(result.data.status, "succeeded");
|
|
188
|
-
assert.
|
|
208
|
+
assert.deepEqual(result.data.result, message);
|
|
189
209
|
assert.deepEqual(result.messages, [{
|
|
190
210
|
role: "assistant",
|
|
191
211
|
parts: message.parts,
|
|
192
212
|
}]);
|
|
193
213
|
});
|
|
194
214
|
|
|
215
|
+
test("ImagePlugin image_result stores remote images locally and preserves source URLs", async (t) => {
|
|
216
|
+
const workspace_path = await fs.mkdtemp(path.join(os.tmpdir(), "image-plugin-result-"));
|
|
217
|
+
t.after(() => fs.rm(workspace_path, { recursive: true, force: true }));
|
|
218
|
+
t.mock.method(globalThis, "fetch", async (url) => {
|
|
219
|
+
const is_webp = String(url).endsWith("/second.webp");
|
|
220
|
+
return new Response(is_webp ? "webp-bytes" : "png-bytes", {
|
|
221
|
+
status: 200,
|
|
222
|
+
headers: {
|
|
223
|
+
"content-type": is_webp ? "image/webp" : "image/png",
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const remote_message = {
|
|
229
|
+
id: "msg_remote_images",
|
|
230
|
+
role: "assistant",
|
|
231
|
+
parts: [
|
|
232
|
+
{
|
|
233
|
+
type: "file",
|
|
234
|
+
mediaType: "image/png",
|
|
235
|
+
url: "https://storage.example.com/first.png",
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
type: "file",
|
|
239
|
+
mediaType: "image/webp",
|
|
240
|
+
url: "https://storage.example.com/second.webp",
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
const plugin = new ImagePlugin({
|
|
245
|
+
image_create: () => ({ job_id: "img_remote", status: "queued" }),
|
|
246
|
+
image_result: () => ({
|
|
247
|
+
job_id: "img_remote",
|
|
248
|
+
status: "succeeded",
|
|
249
|
+
result: remote_message,
|
|
250
|
+
}),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const result = await plugin.actions.image_result.execute({
|
|
254
|
+
context: create_context(workspace_path),
|
|
255
|
+
input: { job_id: "img_remote" },
|
|
256
|
+
plugin_name: "image",
|
|
257
|
+
action_name: "image_result",
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
assert.equal(result.success, true);
|
|
261
|
+
assert.deepEqual(result.data.result.parts.map((part) => part.url), [
|
|
262
|
+
".downcity/image/results/img_remote/image_01.png",
|
|
263
|
+
".downcity/image/results/img_remote/image_02.webp",
|
|
264
|
+
]);
|
|
265
|
+
assert.deepEqual(
|
|
266
|
+
result.data.result.parts.map((part) => part.providerMetadata.downcity.source_url),
|
|
267
|
+
remote_message.parts.map((part) => part.url),
|
|
268
|
+
);
|
|
269
|
+
assert.equal(
|
|
270
|
+
await fs.readFile(path.join(workspace_path, result.data.result.parts[0].url), "utf8"),
|
|
271
|
+
"png-bytes",
|
|
272
|
+
);
|
|
273
|
+
assert.equal(
|
|
274
|
+
await fs.readFile(path.join(workspace_path, result.data.result.parts[1].url), "utf8"),
|
|
275
|
+
"webp-bytes",
|
|
276
|
+
);
|
|
277
|
+
assert.deepEqual(result.messages[0].parts, result.data.result.parts);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("ImagePlugin image_result keeps remote URL when local storage fails", async (t) => {
|
|
281
|
+
const workspace_path = await fs.mkdtemp(path.join(os.tmpdir(), "image-plugin-fallback-"));
|
|
282
|
+
t.after(() => fs.rm(workspace_path, { recursive: true, force: true }));
|
|
283
|
+
t.mock.method(globalThis, "fetch", async () => {
|
|
284
|
+
return new Response("unavailable", { status: 503 });
|
|
285
|
+
});
|
|
286
|
+
const remote_url = "https://storage.example.com/failed.png";
|
|
287
|
+
const plugin = new ImagePlugin({
|
|
288
|
+
image_create: () => ({ job_id: "img_fallback", status: "queued" }),
|
|
289
|
+
image_result: () => ({
|
|
290
|
+
job_id: "img_fallback",
|
|
291
|
+
status: "succeeded",
|
|
292
|
+
result: {
|
|
293
|
+
id: "msg_remote_fallback",
|
|
294
|
+
role: "assistant",
|
|
295
|
+
parts: [{ type: "file", mediaType: "image/png", url: remote_url }],
|
|
296
|
+
},
|
|
297
|
+
}),
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const result = await plugin.actions.image_result.execute({
|
|
301
|
+
context: create_context(workspace_path),
|
|
302
|
+
input: { job_id: "img_fallback" },
|
|
303
|
+
plugin_name: "image",
|
|
304
|
+
action_name: "image_result",
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
assert.equal(result.success, true);
|
|
308
|
+
assert.equal(result.data.result.parts[0].url, remote_url);
|
|
309
|
+
assert.equal(
|
|
310
|
+
result.data.result.parts[0].providerMetadata.downcity.source_url,
|
|
311
|
+
remote_url,
|
|
312
|
+
);
|
|
313
|
+
assert.match(
|
|
314
|
+
result.data.result.parts[0].providerMetadata.downcity.localization_error,
|
|
315
|
+
/HTTP 503/,
|
|
316
|
+
);
|
|
317
|
+
assert.match(result.message, /kept as remote URLs/);
|
|
318
|
+
assert.deepEqual(result.messages[0].parts, result.data.result.parts);
|
|
319
|
+
});
|
|
320
|
+
|
|
195
321
|
test("ImagePlugin image_result reports failed terminal job", async () => {
|
|
196
322
|
const plugin = new ImagePlugin({
|
|
197
323
|
image_create: () => ({ job_id: "img_1", status: "queued", poll_after_ms: 1 }),
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
to_executor_history,
|
|
21
21
|
to_executor_ui_message,
|
|
22
22
|
} from "../bin/session/messages/SessionMessageCodec.js";
|
|
23
|
+
import { to_model_messages } from "../bin/executor/messages/SessionMessageCodec.js";
|
|
23
24
|
import { convertToModelMessages } from "ai";
|
|
24
25
|
import { MockLanguageModelV3 } from "ai/test";
|
|
25
26
|
|
|
@@ -1199,6 +1200,103 @@ test("step 最终快照忽略没有 delta 的空 Text 与 Reasoning Part", async
|
|
|
1199
1200
|
assert.equal(assistant.parts[0].tool_call_id, "call-1");
|
|
1200
1201
|
});
|
|
1201
1202
|
|
|
1203
|
+
test("空 Reasoning 携带 Provider metadata 时作为跨轮重放协议 Part 保存", async () => {
|
|
1204
|
+
const { recorder, file_path } = await create_recorder("empty-provider-reasoning-test");
|
|
1205
|
+
const writer = await recorder.open_assistant_message({
|
|
1206
|
+
turn_id: "turn-empty-provider-reasoning",
|
|
1207
|
+
});
|
|
1208
|
+
const reasoning_metadata = {
|
|
1209
|
+
openai: { itemId: "rs_required" },
|
|
1210
|
+
};
|
|
1211
|
+
const message_metadata = {
|
|
1212
|
+
openai: { itemId: "msg_required", phase: "final_answer" },
|
|
1213
|
+
};
|
|
1214
|
+
|
|
1215
|
+
await writer.begin_step();
|
|
1216
|
+
await writer.apply_chunk({
|
|
1217
|
+
type: "reasoning-start",
|
|
1218
|
+
id: "reasoning-empty-provider",
|
|
1219
|
+
providerMetadata: reasoning_metadata,
|
|
1220
|
+
});
|
|
1221
|
+
await writer.apply_chunk({
|
|
1222
|
+
type: "reasoning-end",
|
|
1223
|
+
id: "reasoning-empty-provider",
|
|
1224
|
+
providerMetadata: reasoning_metadata,
|
|
1225
|
+
});
|
|
1226
|
+
await writer.apply_chunk({ type: "text-start", id: "text-provider" });
|
|
1227
|
+
await writer.apply_chunk({
|
|
1228
|
+
type: "text-delta",
|
|
1229
|
+
id: "text-provider",
|
|
1230
|
+
delta: "完成",
|
|
1231
|
+
});
|
|
1232
|
+
await writer.apply_chunk({
|
|
1233
|
+
type: "text-end",
|
|
1234
|
+
id: "text-provider",
|
|
1235
|
+
providerMetadata: message_metadata,
|
|
1236
|
+
});
|
|
1237
|
+
const final_parts = from_ui_assistant_parts([
|
|
1238
|
+
{
|
|
1239
|
+
type: "reasoning",
|
|
1240
|
+
text: "",
|
|
1241
|
+
state: "done",
|
|
1242
|
+
providerMetadata: reasoning_metadata,
|
|
1243
|
+
},
|
|
1244
|
+
{
|
|
1245
|
+
type: "text",
|
|
1246
|
+
text: "完成",
|
|
1247
|
+
state: "done",
|
|
1248
|
+
providerMetadata: message_metadata,
|
|
1249
|
+
},
|
|
1250
|
+
]);
|
|
1251
|
+
assert.deepEqual(final_parts.map((part) => part.type), ["reasoning", "text"]);
|
|
1252
|
+
await writer.finish_step(final_parts);
|
|
1253
|
+
await writer.complete();
|
|
1254
|
+
|
|
1255
|
+
const assistant = (await read_jsonl(file_path))[0];
|
|
1256
|
+
assert.deepEqual(assistant.parts.map((part) => part.type), ["reasoning", "text"]);
|
|
1257
|
+
assert.equal(assistant.parts[0].text, "");
|
|
1258
|
+
assert.deepEqual(assistant.parts[0].provider_metadata, reasoning_metadata);
|
|
1259
|
+
assert.deepEqual(assistant.parts[1].provider_metadata, message_metadata);
|
|
1260
|
+
|
|
1261
|
+
const restored = to_executor_ui_message(assistant);
|
|
1262
|
+
const model_messages = await to_model_messages([restored], {});
|
|
1263
|
+
const model_parts = model_messages.flatMap((message) =>
|
|
1264
|
+
Array.isArray(message.content) ? message.content : []
|
|
1265
|
+
);
|
|
1266
|
+
assert.deepEqual(model_parts.map((part) => part.type), ["reasoning", "text"]);
|
|
1267
|
+
assert.deepEqual(model_parts[0].providerOptions, reasoning_metadata);
|
|
1268
|
+
assert.deepEqual(model_parts[1].providerOptions, message_metadata);
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
test("旧 Session 缺少 Reasoning 协议 Part 时移除孤立 msg_* 引用", async () => {
|
|
1272
|
+
const model_messages = await to_model_messages([{
|
|
1273
|
+
id: "assistant-legacy",
|
|
1274
|
+
role: "assistant",
|
|
1275
|
+
metadata: {
|
|
1276
|
+
v: 1,
|
|
1277
|
+
ts: 1,
|
|
1278
|
+
session_id: "legacy-session",
|
|
1279
|
+
source: "egress",
|
|
1280
|
+
kind: "normal",
|
|
1281
|
+
},
|
|
1282
|
+
parts: [{
|
|
1283
|
+
type: "text",
|
|
1284
|
+
text: "已持久化的回复",
|
|
1285
|
+
state: "done",
|
|
1286
|
+
providerMetadata: {
|
|
1287
|
+
openai: { itemId: "msg_orphaned", phase: "final_answer" },
|
|
1288
|
+
},
|
|
1289
|
+
}],
|
|
1290
|
+
}], {});
|
|
1291
|
+
const text_part = model_messages
|
|
1292
|
+
.flatMap((message) => Array.isArray(message.content) ? message.content : [])
|
|
1293
|
+
.find((part) => part.type === "text");
|
|
1294
|
+
assert.equal(text_part.text, "已持久化的回复");
|
|
1295
|
+
assert.deepEqual(text_part.providerOptions, {
|
|
1296
|
+
openai: { phase: "final_answer" },
|
|
1297
|
+
});
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1202
1300
|
test("空 Text Start 不会抢占后续 Tool 的真实顺序", async () => {
|
|
1203
1301
|
const { recorder, events, file_path } = await create_recorder("deferred-text-order-test");
|
|
1204
1302
|
const writer = await recorder.open_assistant_message({ turn_id: "turn-1" });
|
|
@@ -84,7 +84,9 @@ export function deep_compact_model_messages(
|
|
|
84
84
|
const depth = Math.max(0, Math.min(8, Math.floor(compact_depth)));
|
|
85
85
|
const pruned_messages = pruneMessages({
|
|
86
86
|
messages,
|
|
87
|
-
|
|
87
|
+
// Reasoning 由本模块在确定最新 Tool 事务后再清理,避免先丢失
|
|
88
|
+
// OpenAI Responses API 中与 Tool item 原子关联的协议 Part。
|
|
89
|
+
reasoning: "none",
|
|
88
90
|
// 工具事务由本模块按 toolCallId/approvalId 选择;先让 SDK 保留完整关联,
|
|
89
91
|
// 避免 approval response 作为最后一条消息时 SDK 丢掉更早的 tool-call。
|
|
90
92
|
toolCalls: "none",
|
|
@@ -343,25 +345,38 @@ function compact_retained_message(
|
|
|
343
345
|
content: fold_compacted_text(message.content, message_limit),
|
|
344
346
|
} as ModelMessage;
|
|
345
347
|
}
|
|
346
|
-
const
|
|
348
|
+
const retained_non_reasoning_parts = message.content.filter((part) => {
|
|
349
|
+
if (part.type === "reasoning") return false;
|
|
350
|
+
if (part.type === "tool-call" || part.type === "tool-result") {
|
|
351
|
+
return selected_tool_call_ids.has(part.toolCallId);
|
|
352
|
+
}
|
|
353
|
+
if (part.type === "tool-approval-request") {
|
|
354
|
+
return selected_tool_call_ids.has(part.toolCallId);
|
|
355
|
+
}
|
|
356
|
+
if (part.type === "tool-approval-response") {
|
|
357
|
+
const tool_call_id = approval_to_tool_call.get(part.approvalId);
|
|
358
|
+
return Boolean(tool_call_id && selected_tool_call_ids.has(tool_call_id));
|
|
359
|
+
}
|
|
360
|
+
return true;
|
|
361
|
+
});
|
|
362
|
+
const requires_provider_reasoning = retained_non_reasoning_parts.some(
|
|
363
|
+
(part) =>
|
|
364
|
+
(part.type === "tool-call" || part.type === "tool-result") &&
|
|
365
|
+
has_openai_item_reference(part as unknown as Record<string, unknown>),
|
|
366
|
+
);
|
|
367
|
+
const relevant_parts = message.content.filter((part) =>
|
|
368
|
+
part.type !== "reasoning" ||
|
|
369
|
+
(requires_provider_reasoning &&
|
|
370
|
+
has_openai_reasoning_replay_data(part as unknown as Record<string, unknown>))
|
|
371
|
+
);
|
|
347
372
|
const part_limit = Math.max(
|
|
348
373
|
MIN_FOLDED_PART_CHARS,
|
|
349
374
|
Math.floor(message_limit / Math.max(1, relevant_parts.length)),
|
|
350
375
|
);
|
|
351
376
|
const content = relevant_parts
|
|
352
|
-
.filter((part) =>
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}
|
|
356
|
-
if (part.type === "tool-approval-request") {
|
|
357
|
-
return selected_tool_call_ids.has(part.toolCallId);
|
|
358
|
-
}
|
|
359
|
-
if (part.type === "tool-approval-response") {
|
|
360
|
-
const tool_call_id = approval_to_tool_call.get(part.approvalId);
|
|
361
|
-
return Boolean(tool_call_id && selected_tool_call_ids.has(tool_call_id));
|
|
362
|
-
}
|
|
363
|
-
return true;
|
|
364
|
-
})
|
|
377
|
+
.filter((part) =>
|
|
378
|
+
part.type === "reasoning" || retained_non_reasoning_parts.includes(part)
|
|
379
|
+
)
|
|
365
380
|
.map((part) =>
|
|
366
381
|
compact_model_part(
|
|
367
382
|
part as unknown as Record<string, unknown>,
|
|
@@ -380,8 +395,14 @@ function compact_model_part(
|
|
|
380
395
|
return {
|
|
381
396
|
...part,
|
|
382
397
|
text: fold_compacted_text(String(part.text || ""), part_limit),
|
|
398
|
+
...without_openai_item_reference(part),
|
|
383
399
|
};
|
|
384
400
|
}
|
|
401
|
+
if (part.type === "reasoning") {
|
|
402
|
+
// 压缩不保留可见思维文本,但必须保留与最新 Tool 事务关联的
|
|
403
|
+
// Responses API itemId / encrypted content,否则 Provider 会拒绝孤立的 Tool item。
|
|
404
|
+
return { ...part, text: "" };
|
|
405
|
+
}
|
|
385
406
|
if (part.type === "tool-call") {
|
|
386
407
|
return {
|
|
387
408
|
...part,
|
|
@@ -420,6 +441,54 @@ function compact_model_part(
|
|
|
420
441
|
return part;
|
|
421
442
|
}
|
|
422
443
|
|
|
444
|
+
/** 判断 Part 是否引用 OpenAI Responses API 中已存储的 item。 */
|
|
445
|
+
function has_openai_item_reference(part: Record<string, unknown>): boolean {
|
|
446
|
+
const provider_options = read_record(part.providerOptions);
|
|
447
|
+
const openai_options = read_record(provider_options?.openai);
|
|
448
|
+
return typeof openai_options?.itemId === "string" && openai_options.itemId.length > 0;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** 判断空 Reasoning 是否仍携带可用于跨轮重放的 Provider 协议数据。 */
|
|
452
|
+
function has_openai_reasoning_replay_data(part: Record<string, unknown>): boolean {
|
|
453
|
+
const provider_options = read_record(part.providerOptions);
|
|
454
|
+
const openai_options = read_record(provider_options?.openai);
|
|
455
|
+
return (
|
|
456
|
+
(typeof openai_options?.itemId === "string" && openai_options.itemId.length > 0) ||
|
|
457
|
+
(typeof openai_options?.reasoningEncryptedContent === "string" &&
|
|
458
|
+
openai_options.reasoningEncryptedContent.length > 0)
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* 压缩后的 Text 已经是新的语义投影,不能继续引用 Provider 中的原始 msg_* item。
|
|
464
|
+
*/
|
|
465
|
+
function without_openai_item_reference(
|
|
466
|
+
part: Record<string, unknown>,
|
|
467
|
+
): Pick<Record<string, unknown>, "providerOptions"> | Record<string, never> {
|
|
468
|
+
const provider_options = read_record(part.providerOptions);
|
|
469
|
+
const openai_options = read_record(provider_options?.openai);
|
|
470
|
+
if (!provider_options || !openai_options || !("itemId" in openai_options)) {
|
|
471
|
+
return {};
|
|
472
|
+
}
|
|
473
|
+
const { itemId: _item_id, ...remaining_openai_options } = openai_options;
|
|
474
|
+
const next_provider_options = { ...provider_options };
|
|
475
|
+
if (Object.keys(remaining_openai_options).length > 0) {
|
|
476
|
+
next_provider_options.openai = remaining_openai_options;
|
|
477
|
+
} else {
|
|
478
|
+
delete next_provider_options.openai;
|
|
479
|
+
}
|
|
480
|
+
return Object.keys(next_provider_options).length > 0
|
|
481
|
+
? { providerOptions: next_provider_options }
|
|
482
|
+
: { providerOptions: undefined };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** 安全读取普通 JSON object。 */
|
|
486
|
+
function read_record(value: unknown): Record<string, unknown> | undefined {
|
|
487
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
488
|
+
? value as Record<string, unknown>
|
|
489
|
+
: undefined;
|
|
490
|
+
}
|
|
491
|
+
|
|
423
492
|
function fold_compacted_value(value: unknown, max_chars: number): unknown {
|
|
424
493
|
const serialized = safe_stringify(value);
|
|
425
494
|
if (serialized.length <= max_chars) return value;
|
|
@@ -96,10 +96,78 @@ export async function to_model_messages(
|
|
|
96
96
|
});
|
|
97
97
|
|
|
98
98
|
// 调用 ai-sdk 的转换函数。
|
|
99
|
-
|
|
99
|
+
const converted_messages = await convertToModelMessages(input, {
|
|
100
100
|
// 如果当前轮有工具,就把工具注入转换选项。
|
|
101
101
|
...(tools && Object.keys(tools).length > 0 ? { tools: tools as ToolSet } : {}),
|
|
102
102
|
// 忽略历史里的不完整工具调用,提升容错性。
|
|
103
103
|
ignoreIncompleteToolCalls: true,
|
|
104
104
|
});
|
|
105
|
+
return repair_orphaned_openai_text_references(converted_messages);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 修复旧版 Session 中“只保存 msg_*、未保存必需 rs_*”的孤立 Responses API 引用。
|
|
110
|
+
*
|
|
111
|
+
* 关键点(中文)
|
|
112
|
+
* - reasoning itemId / encrypted content 与 message itemId 存在时,保留 Provider 原子重放。
|
|
113
|
+
* - 只有 message itemId 时,删除该引用并发送已持久化的普通文本,避免 400。
|
|
114
|
+
* - 不修改 Session canonical source,该修复是可重建的 Provider 投影。
|
|
115
|
+
*/
|
|
116
|
+
function repair_orphaned_openai_text_references(
|
|
117
|
+
messages: ModelMessage[],
|
|
118
|
+
): ModelMessage[] {
|
|
119
|
+
return messages.map((message) => {
|
|
120
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) {
|
|
121
|
+
return message;
|
|
122
|
+
}
|
|
123
|
+
const has_reasoning_replay_data = message.content.some((part) => {
|
|
124
|
+
if (part.type !== "reasoning") return false;
|
|
125
|
+
const openai_options = read_openai_provider_options(part.providerOptions);
|
|
126
|
+
return Boolean(
|
|
127
|
+
(typeof openai_options?.itemId === "string" && openai_options.itemId) ||
|
|
128
|
+
(typeof openai_options?.reasoningEncryptedContent === "string" &&
|
|
129
|
+
openai_options.reasoningEncryptedContent),
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
if (has_reasoning_replay_data) return message;
|
|
133
|
+
|
|
134
|
+
let changed = false;
|
|
135
|
+
const content = message.content.map((part) => {
|
|
136
|
+
if (part.type !== "text") return part;
|
|
137
|
+
const provider_options = read_json_record(part.providerOptions);
|
|
138
|
+
const openai_options = read_openai_provider_options(part.providerOptions);
|
|
139
|
+
if (!provider_options || !openai_options || !("itemId" in openai_options)) {
|
|
140
|
+
return part;
|
|
141
|
+
}
|
|
142
|
+
changed = true;
|
|
143
|
+
const { itemId: _item_id, ...remaining_openai_options } = openai_options;
|
|
144
|
+
const next_provider_options = { ...provider_options };
|
|
145
|
+
if (Object.keys(remaining_openai_options).length > 0) {
|
|
146
|
+
next_provider_options.openai = remaining_openai_options;
|
|
147
|
+
} else {
|
|
148
|
+
delete next_provider_options.openai;
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
...part,
|
|
152
|
+
providerOptions: Object.keys(next_provider_options).length > 0
|
|
153
|
+
? next_provider_options
|
|
154
|
+
: undefined,
|
|
155
|
+
};
|
|
156
|
+
});
|
|
157
|
+
return changed ? { ...message, content } as ModelMessage : message;
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 读取 Provider options 中的 OpenAI 协议字段。 */
|
|
162
|
+
function read_openai_provider_options(
|
|
163
|
+
value: unknown,
|
|
164
|
+
): Record<string, unknown> | undefined {
|
|
165
|
+
return read_json_record(read_json_record(value)?.openai);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 安全读取普通 JSON object。 */
|
|
169
|
+
function read_json_record(value: unknown): Record<string, unknown> | undefined {
|
|
170
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
171
|
+
? value as Record<string, unknown>
|
|
172
|
+
: undefined;
|
|
105
173
|
}
|
|
@@ -156,9 +156,21 @@ export class SessionAssistantMessageWriter {
|
|
|
156
156
|
const source_part_id = this.source_text_part_id(type, chunk.id);
|
|
157
157
|
const part_id = this.active_text_part_ids.get(source_part_id);
|
|
158
158
|
if (!part_id) return;
|
|
159
|
-
const
|
|
159
|
+
const provider_metadata = to_session_provider_metadata(chunk.providerMetadata);
|
|
160
|
+
const pending = this.pending_text_parts.get(part_id);
|
|
161
|
+
// 关键点(中文):Responses API 可能只输出 reasoning start/end 与 itemId,
|
|
162
|
+
// 却没有可见 reasoning delta。这个空 Part 不是 UI 占位,而是后续 msg_* 重放必需的协议关联。
|
|
163
|
+
if (
|
|
164
|
+
type === "reasoning" &&
|
|
165
|
+
!current.parts.some((item) => item.part_id === part_id) &&
|
|
166
|
+
(provider_metadata !== undefined || pending?.provider_metadata !== undefined)
|
|
167
|
+
) {
|
|
168
|
+
await this.ensure_text_part(part_id, type, provider_metadata);
|
|
169
|
+
}
|
|
170
|
+
const part = this.current_message().parts.find(
|
|
171
|
+
(item) => item.part_id === part_id,
|
|
172
|
+
);
|
|
160
173
|
if (part?.type === "text" || part?.type === "reasoning") {
|
|
161
|
-
const provider_metadata = to_session_provider_metadata(chunk.providerMetadata);
|
|
162
174
|
await this.upsert_part({
|
|
163
175
|
...part,
|
|
164
176
|
state: "done",
|
|
@@ -154,10 +154,14 @@ export function from_ui_assistant_parts(
|
|
|
154
154
|
const type = String(candidate.type || "");
|
|
155
155
|
if (type === "text" || type === "reasoning") {
|
|
156
156
|
const text = String(candidate.text || "");
|
|
157
|
-
// 关键点(中文):AI SDK 会为只有 start/end、没有 delta 的流生成空占位 Part。
|
|
158
|
-
// canonical history 不保存无内容的协议占位,避免与只按 delta 创建 Part 的 writer 分叉。
|
|
159
|
-
if (text.length === 0) return [];
|
|
160
157
|
const provider_metadata = to_session_provider_metadata(candidate.providerMetadata);
|
|
158
|
+
// 关键点(中文):AI SDK 会为只有 start/end、没有 delta 的流生成空占位 Part。
|
|
159
|
+
// 纯空占位不保存;但 Responses API 的 reasoning 即使没有可见文本,
|
|
160
|
+
// 也可能通过 itemId / encrypted content 与后续 message 形成必须原子重放的协议组。
|
|
161
|
+
if (
|
|
162
|
+
text.length === 0 &&
|
|
163
|
+
!(type === "reasoning" && provider_metadata !== undefined)
|
|
164
|
+
) return [];
|
|
161
165
|
return [{
|
|
162
166
|
part_id: `${type}:${index + 1}`,
|
|
163
167
|
sequence: index + 1,
|