@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.
@@ -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 { workspace_path };
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.equal("result" in result.data, false);
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
- reasoning: "all",
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 relevant_parts = message.content.filter((part) => part.type !== "reasoning");
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
- if (part.type === "tool-call" || part.type === "tool-result") {
354
- return selected_tool_call_ids.has(part.toolCallId);
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
- return await convertToModelMessages(input, {
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 part = current.parts.find((item) => item.part_id === part_id);
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,