@downcity/agent 1.1.332 → 1.1.337

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 }),
@@ -1199,6 +1199,66 @@ test("step 最终快照忽略没有 delta 的空 Text 与 Reasoning Part", async
1199
1199
  assert.equal(assistant.parts[0].tool_call_id, "call-1");
1200
1200
  });
1201
1201
 
1202
+ test("空 Reasoning 携带 Provider metadata 时作为跨轮重放协议 Part 保存", async () => {
1203
+ const { recorder, file_path } = await create_recorder("empty-provider-reasoning-test");
1204
+ const writer = await recorder.open_assistant_message({
1205
+ turn_id: "turn-empty-provider-reasoning",
1206
+ });
1207
+ const reasoning_metadata = {
1208
+ openai: { itemId: "rs_required" },
1209
+ };
1210
+ const message_metadata = {
1211
+ openai: { itemId: "msg_required", phase: "final_answer" },
1212
+ };
1213
+
1214
+ await writer.begin_step();
1215
+ await writer.apply_chunk({
1216
+ type: "reasoning-start",
1217
+ id: "reasoning-empty-provider",
1218
+ providerMetadata: reasoning_metadata,
1219
+ });
1220
+ await writer.apply_chunk({
1221
+ type: "reasoning-end",
1222
+ id: "reasoning-empty-provider",
1223
+ providerMetadata: reasoning_metadata,
1224
+ });
1225
+ await writer.apply_chunk({ type: "text-start", id: "text-provider" });
1226
+ await writer.apply_chunk({
1227
+ type: "text-delta",
1228
+ id: "text-provider",
1229
+ delta: "完成",
1230
+ });
1231
+ await writer.apply_chunk({
1232
+ type: "text-end",
1233
+ id: "text-provider",
1234
+ providerMetadata: message_metadata,
1235
+ });
1236
+ const final_parts = from_ui_assistant_parts([
1237
+ {
1238
+ type: "reasoning",
1239
+ text: "",
1240
+ state: "done",
1241
+ providerMetadata: reasoning_metadata,
1242
+ },
1243
+ {
1244
+ type: "text",
1245
+ text: "完成",
1246
+ state: "done",
1247
+ providerMetadata: message_metadata,
1248
+ },
1249
+ ]);
1250
+ assert.deepEqual(final_parts.map((part) => part.type), ["reasoning", "text"]);
1251
+ await writer.finish_step(final_parts);
1252
+ await writer.complete();
1253
+
1254
+ const assistant = (await read_jsonl(file_path))[0];
1255
+ assert.deepEqual(assistant.parts.map((part) => part.type), ["reasoning", "text"]);
1256
+ assert.equal(assistant.parts[0].text, "");
1257
+ assert.deepEqual(assistant.parts[0].provider_metadata, reasoning_metadata);
1258
+ assert.deepEqual(assistant.parts[1].provider_metadata, message_metadata);
1259
+
1260
+ });
1261
+
1202
1262
  test("空 Text Start 不会抢占后续 Tool 的真实顺序", async () => {
1203
1263
  const { recorder, events, file_path } = await create_recorder("deferred-text-order-test");
1204
1264
  const writer = await recorder.open_assistant_message({ turn_id: "turn-1" });
@@ -84,6 +84,7 @@ 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
+ // 压缩后只保留语义内容;Provider replay 状态由 Federation 在最终路由后按作用域决定。
87
88
  reasoning: "all",
88
89
  // 工具事务由本模块按 toolCallId/approvalId 选择;先让 SDK 保留完整关联,
89
90
  // 避免 approval response 作为最后一条消息时 SDK 丢掉更早的 tool-call。
@@ -343,25 +344,24 @@ function compact_retained_message(
343
344
  content: fold_compacted_text(message.content, message_limit),
344
345
  } as ModelMessage;
345
346
  }
346
- const relevant_parts = message.content.filter((part) => part.type !== "reasoning");
347
+ const relevant_parts = message.content.filter((part) => {
348
+ if (part.type === "tool-call" || part.type === "tool-result") {
349
+ return selected_tool_call_ids.has(part.toolCallId);
350
+ }
351
+ if (part.type === "tool-approval-request") {
352
+ return selected_tool_call_ids.has(part.toolCallId);
353
+ }
354
+ if (part.type === "tool-approval-response") {
355
+ const tool_call_id = approval_to_tool_call.get(part.approvalId);
356
+ return Boolean(tool_call_id && selected_tool_call_ids.has(tool_call_id));
357
+ }
358
+ return part.type !== "reasoning";
359
+ });
347
360
  const part_limit = Math.max(
348
361
  MIN_FOLDED_PART_CHARS,
349
362
  Math.floor(message_limit / Math.max(1, relevant_parts.length)),
350
363
  );
351
364
  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
- })
365
365
  .map((part) =>
366
366
  compact_model_part(
367
367
  part as unknown as Record<string, unknown>,
@@ -376,23 +376,24 @@ function compact_model_part(
376
376
  part: Record<string, unknown>,
377
377
  part_limit: number,
378
378
  ): Record<string, unknown> {
379
+ const semantic_part = strip_provider_replay_state(part);
379
380
  if (part.type === "text") {
380
381
  return {
381
- ...part,
382
+ ...semantic_part,
382
383
  text: fold_compacted_text(String(part.text || ""), part_limit),
383
384
  };
384
385
  }
385
386
  if (part.type === "tool-call") {
386
387
  return {
387
- ...part,
388
+ ...semantic_part,
388
389
  input: fold_compacted_value(part.input, part_limit),
389
390
  };
390
391
  }
391
392
  if (part.type === "tool-result") {
392
393
  const serialized_output = safe_stringify(part.output);
393
- if (serialized_output.length <= part_limit) return part;
394
+ if (serialized_output.length <= part_limit) return semantic_part;
394
395
  return {
395
- ...part,
396
+ ...semantic_part,
396
397
  output: {
397
398
  type: "text",
398
399
  value: fold_compacted_text(serialized_output, part_limit),
@@ -417,7 +418,19 @@ function compact_model_part(
417
418
  };
418
419
  }
419
420
  }
420
- return part;
421
+ return semantic_part;
422
+ }
423
+
424
+ /** 压缩后的模型 Part 只作为语义历史传递,不继续携带任何 Provider replay state。 */
425
+ function strip_provider_replay_state(
426
+ part: Record<string, unknown>,
427
+ ): Record<string, unknown> {
428
+ const {
429
+ providerOptions: _provider_options,
430
+ providerMetadata: _provider_metadata,
431
+ ...semantic_part
432
+ } = part;
433
+ return semantic_part;
421
434
  }
422
435
 
423
436
  function fold_compacted_value(value: unknown, max_chars: number): unknown {
@@ -96,10 +96,11 @@ 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 converted_messages;
105
106
  }
@@ -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,