@downcity/agent 1.1.267 → 1.1.269

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/bin/executor/core-engine/CoreEngineRunner.d.ts.map +1 -1
  2. package/bin/executor/core-engine/CoreEngineRunner.js +1 -2
  3. package/bin/executor/core-engine/CoreEngineRunner.js.map +1 -1
  4. package/bin/executor/services/ExecutorRecoveryPolicy.d.ts +0 -1
  5. package/bin/executor/services/ExecutorRecoveryPolicy.d.ts.map +1 -1
  6. package/bin/executor/services/ExecutorRecoveryPolicy.js +2 -17
  7. package/bin/executor/services/ExecutorRecoveryPolicy.js.map +1 -1
  8. package/bin/executor/types/SessionRecords.d.ts +5 -7
  9. package/bin/executor/types/SessionRecords.d.ts.map +1 -1
  10. package/bin/executor/types/SessionRecords.js +3 -4
  11. package/bin/executor/types/SessionRecords.js.map +1 -1
  12. package/bin/index.d.ts +1 -1
  13. package/bin/index.d.ts.map +1 -1
  14. package/bin/index.js.map +1 -1
  15. package/bin/session/SessionMessages.d.ts.map +1 -1
  16. package/bin/session/SessionMessages.js +14 -1
  17. package/bin/session/SessionMessages.js.map +1 -1
  18. package/bin/session/SessionTitle.d.ts.map +1 -1
  19. package/bin/session/SessionTitle.js +0 -6
  20. package/bin/session/SessionTitle.js.map +1 -1
  21. package/bin/session/SessionTurn.d.ts.map +1 -1
  22. package/bin/session/SessionTurn.js +0 -25
  23. package/bin/session/SessionTurn.js.map +1 -1
  24. package/bin/session/messages/SessionAssistantMessageWriter.d.ts +1 -1
  25. package/bin/session/messages/SessionAssistantMessageWriter.d.ts.map +1 -1
  26. package/bin/session/messages/SessionAssistantMessageWriter.js +153 -12
  27. package/bin/session/messages/SessionAssistantMessageWriter.js.map +1 -1
  28. package/bin/session/messages/SessionJsonValue.d.ts +3 -1
  29. package/bin/session/messages/SessionJsonValue.d.ts.map +1 -1
  30. package/bin/session/messages/SessionJsonValue.js +12 -0
  31. package/bin/session/messages/SessionJsonValue.js.map +1 -1
  32. package/bin/session/messages/SessionMessageCodec.d.ts.map +1 -1
  33. package/bin/session/messages/SessionMessageCodec.js +226 -19
  34. package/bin/session/messages/SessionMessageCodec.js.map +1 -1
  35. package/bin/types/sdk/AgentSessionTurn.d.ts +2 -1
  36. package/bin/types/sdk/AgentSessionTurn.d.ts.map +1 -1
  37. package/bin/types/session/SessionMessage.d.ts +87 -3
  38. package/bin/types/session/SessionMessage.d.ts.map +1 -1
  39. package/package.json +3 -3
  40. package/scripts/executor-failure-result.test.mjs +81 -0
  41. package/scripts/openai-responses-continuation.test.mjs +35 -5
  42. package/scripts/session-messages.test.mjs +287 -1
  43. package/scripts/session-shell-approval.test.mjs +3 -3
  44. package/scripts/session-turn-failure.test.mjs +101 -0
  45. package/src/executor/core-engine/CoreEngineRunner.ts +1 -5
  46. package/src/executor/services/ExecutorRecoveryPolicy.ts +2 -23
  47. package/src/executor/types/SessionRecords.ts +5 -7
  48. package/src/index.ts +11 -0
  49. package/src/session/SessionMessages.ts +14 -1
  50. package/src/session/SessionTitle.ts +0 -6
  51. package/src/session/SessionTurn.ts +0 -29
  52. package/src/session/messages/SessionAssistantMessageWriter.ts +170 -12
  53. package/src/session/messages/SessionJsonValue.ts +12 -1
  54. package/src/session/messages/SessionMessageCodec.ts +226 -18
  55. package/src/types/sdk/AgentSessionTurn.ts +2 -1
  56. package/src/types/session/SessionMessage.ts +96 -3
  57. package/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@downcity/agent",
3
- "version": "1.1.267",
3
+ "version": "1.1.269",
4
4
  "type": "module",
5
5
  "description": "Downcity Agent 运行时 — 单 Agent 执行壳与本机 RPC 能力",
6
6
  "main": "./bin/index.js",
@@ -12,8 +12,8 @@
12
12
  }
13
13
  },
14
14
  "dependencies": {
15
- "@downcity/shell": "^0.1.123",
16
- "@downcity/type": "0.1.153",
15
+ "@downcity/shell": "^0.1.125",
16
+ "@downcity/type": "0.1.155",
17
17
  "ai": "^6.0.193",
18
18
  "commander": "^15.0.0",
19
19
  "dotenv": "^17.4.2",
@@ -0,0 +1,81 @@
1
+ /**
2
+ * @file 验证执行器失败结果不会伪造 Assistant 正文。
3
+ */
4
+
5
+ import assert from "node:assert/strict";
6
+ import test from "node:test";
7
+ import { MockLanguageModelV3 } from "ai/test";
8
+
9
+ import { CoreEngineRunner } from "../bin/executor/core-engine/CoreEngineRunner.js";
10
+ import { ExecutorRecoveryPolicy } from "../bin/executor/services/ExecutorRecoveryPolicy.js";
11
+
12
+ function create_run_context() {
13
+ return {
14
+ sessionId: "executor-failure-test",
15
+ injectedUserMessages: [],
16
+ deferredPersistedUserMessages: [],
17
+ pendingAssistantFileParts: [],
18
+ };
19
+ }
20
+
21
+ test("CoreEngine Provider 失败时只返回结构化错误", async () => {
22
+ const model = new MockLanguageModelV3({
23
+ modelId: "failing-model",
24
+ doStream: async () => {
25
+ throw new Error("quota exceeded");
26
+ },
27
+ });
28
+ const runner = new CoreEngineRunner({
29
+ session_id: "executor-failure-test",
30
+ logger: { log: async () => {} },
31
+ should_compact_on_error: () => false,
32
+ });
33
+ const messages = [{
34
+ id: "user-1",
35
+ role: "user",
36
+ metadata: {
37
+ v: 1,
38
+ ts: 1,
39
+ sessionId: "executor-failure-test",
40
+ source: "ingress",
41
+ kind: "normal",
42
+ },
43
+ parts: [{ type: "text", text: "hello" }],
44
+ }];
45
+
46
+ const result = await runner.run({
47
+ execute_input: { query: "hello", system: [], messages, tools: {} },
48
+ model,
49
+ run_context: create_run_context(),
50
+ resolve_step_inputs: async () => ({ model, system: [], tools: {} }),
51
+ reload_history: async () => messages,
52
+ });
53
+
54
+ assert.equal(result.success, false);
55
+ assert.match(result.error, /quota exceeded/);
56
+ assert.equal(result.assistantMessage, undefined);
57
+ });
58
+
59
+ test("恢复策略捕获普通异常后只返回结构化错误", async () => {
60
+ const policy = new ExecutorRecoveryPolicy({
61
+ session_id: "executor-failure-test",
62
+ logger: { log: async () => {} },
63
+ should_compact: () => false,
64
+ });
65
+ const run_context = create_run_context();
66
+ const result = await policy.run_with_retry({
67
+ query: "hello",
68
+ model: {},
69
+ run_context,
70
+ prepare_execute_input: async () => {
71
+ throw new Error("configuration failed");
72
+ },
73
+ execute_prepared_run: async () => {
74
+ throw new Error("unexpected execution");
75
+ },
76
+ });
77
+
78
+ assert.equal(result.success, false);
79
+ assert.match(result.error, /configuration failed/);
80
+ assert.equal(result.assistantMessage, undefined);
81
+ });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @file 验证 AI SDK OpenAI Responses 使用持久化 itemId 生成 HTTP item_reference
2
+ * @file 验证 Session 使用完整工具事务续接 OpenAI Responses。
3
3
  */
4
4
 
5
5
  import test from "node:test";
@@ -7,7 +7,8 @@ import assert from "node:assert/strict";
7
7
  import { createOpenAI } from "@ai-sdk/openai";
8
8
  import { convertToModelMessages } from "ai";
9
9
 
10
- test("OpenAI Responses 默认 store 策略使用 item_reference continuation", async () => {
10
+ /** 捕获 AI SDK 在指定 store 策略下生成的 Responses 请求体。 */
11
+ async function capture_request_body(store) {
11
12
  let request_body;
12
13
  const openai = createOpenAI({
13
14
  apiKey: "test-api-key",
@@ -30,11 +31,25 @@ test("OpenAI Responses 默认 store 策略使用 item_reference continuation", a
30
31
  }]);
31
32
 
32
33
  await assert.rejects(
33
- openai.responses("gpt-5").doGenerate({ prompt: messages }),
34
+ openai.responses("gpt-5").doGenerate({
35
+ prompt: messages,
36
+ providerOptions: { openai: { store } },
37
+ }),
34
38
  /request captured/,
35
39
  );
40
+ return request_body;
41
+ }
42
+
43
+ test("Responses store=false 发送完整 function_call", async () => {
44
+ const request_body = await capture_request_body(false);
36
45
  assert.deepEqual(request_body.input, [
37
- { type: "item_reference", id: "fc_1" },
46
+ {
47
+ type: "function_call",
48
+ call_id: "call_1",
49
+ name: "lookup",
50
+ arguments: "{\"q\":\"x\"}",
51
+ id: "fc_1",
52
+ },
38
53
  {
39
54
  type: "function_call_output",
40
55
  call_id: "call_1",
@@ -42,7 +57,22 @@ test("OpenAI Responses 默认 store 策略使用 item_reference continuation", a
42
57
  },
43
58
  ]);
44
59
  assert.equal(
45
- request_body.input.some((item) => item.type === "function_call"),
60
+ request_body.input.some((item) => item.type === "item_reference"),
46
61
  false,
47
62
  );
48
63
  });
64
+
65
+ test("Responses store=true 使用已存储 item_reference", async () => {
66
+ const request_body = await capture_request_body(true);
67
+ assert.deepEqual(request_body.input, [
68
+ {
69
+ type: "item_reference",
70
+ id: "fc_1",
71
+ },
72
+ {
73
+ type: "function_call_output",
74
+ call_id: "call_1",
75
+ output: "ok",
76
+ },
77
+ ]);
78
+ });
@@ -14,6 +14,7 @@ import { SessionApprovalBroker } from "../bin/session/approval/SessionApprovalBr
14
14
  import { compose_session_compaction } from "../bin/session/messages/SessionMessageCompaction.js";
15
15
  import {
16
16
  from_ui_assistant_parts,
17
+ from_ui_user_parts,
17
18
  to_executor_history,
18
19
  to_executor_ui_message,
19
20
  } from "../bin/session/messages/SessionMessageCodec.js";
@@ -281,6 +282,126 @@ test("Streaming Tool Provider metadata 在输入和输出状态间完整保留",
281
282
  assert.equal(tool_part.state, "completed");
282
283
  });
283
284
 
285
+ test("AI SDK 流式 metadata、source、data、step 和审批状态完整持久化", async () => {
286
+ const { recorder, file_path } = await create_recorder("ui-chunk-semantics-test");
287
+ const writer = await recorder.open_assistant_message({
288
+ turn_id: "turn-ui-chunks",
289
+ segment_index: 1,
290
+ });
291
+ await writer.apply_chunk({
292
+ type: "text-start",
293
+ id: "text_1",
294
+ providerMetadata: { openai: { itemId: "text_start" } },
295
+ });
296
+ await writer.apply_chunk({ type: "text-delta", id: "text_1", delta: "hello" });
297
+ await writer.apply_chunk({
298
+ type: "text-end",
299
+ id: "text_1",
300
+ providerMetadata: { openai: { itemId: "text_end" } },
301
+ });
302
+ await writer.apply_chunk({ type: "reasoning-start", id: "reasoning_1" });
303
+ await writer.apply_chunk({
304
+ type: "reasoning-delta",
305
+ id: "reasoning_1",
306
+ delta: "think",
307
+ providerMetadata: { openai: { itemId: "reasoning_delta" } },
308
+ });
309
+ await writer.apply_chunk({ type: "reasoning-end", id: "reasoning_1" });
310
+ await writer.apply_chunk({
311
+ type: "source-url",
312
+ sourceId: "source_1",
313
+ url: "https://example.com/old",
314
+ });
315
+ await writer.apply_chunk({
316
+ type: "source-url",
317
+ sourceId: "source_1",
318
+ url: "https://example.com/new",
319
+ title: "Updated source",
320
+ providerMetadata: { openai: { itemId: "source_item" } },
321
+ });
322
+ await writer.apply_chunk({
323
+ type: "data-progress",
324
+ id: "progress_1",
325
+ data: { percent: 10 },
326
+ });
327
+ await writer.apply_chunk({
328
+ type: "data-progress",
329
+ id: "progress_1",
330
+ data: { percent: 90 },
331
+ });
332
+ await writer.apply_chunk({
333
+ type: "data-progress",
334
+ id: "transient_1",
335
+ data: { percent: 100 },
336
+ transient: true,
337
+ });
338
+ await writer.apply_chunk({
339
+ type: "tool-input-start",
340
+ toolCallId: "call_approval",
341
+ toolName: "lookup",
342
+ title: "Lookup",
343
+ dynamic: true,
344
+ providerMetadata: { openai: { itemId: "fc_approval" } },
345
+ });
346
+ await writer.apply_chunk({
347
+ type: "tool-input-available",
348
+ toolCallId: "call_approval",
349
+ toolName: "lookup",
350
+ input: { q: "downcity" },
351
+ toolMetadata: { category: "search" },
352
+ });
353
+ await writer.apply_chunk({
354
+ type: "tool-approval-request",
355
+ approvalId: "approval_stream_1",
356
+ toolCallId: "call_approval",
357
+ });
358
+ await writer.apply_chunk({
359
+ type: "tool-output-denied",
360
+ toolCallId: "call_approval",
361
+ });
362
+ await writer.apply_chunk({
363
+ type: "file",
364
+ mediaType: "image/png",
365
+ url: "https://example.com/output.png",
366
+ providerMetadata: { openai: { fileId: "output_file" } },
367
+ });
368
+ await writer.apply_chunk({ type: "start-step" });
369
+ await writer.complete();
370
+
371
+ const assistant = (await read_jsonl(file_path))[0];
372
+ assert.deepEqual(assistant.parts.map((part) => part.type), [
373
+ "text",
374
+ "reasoning",
375
+ "source",
376
+ "data",
377
+ "tool",
378
+ "file",
379
+ "step-start",
380
+ ]);
381
+ assert.deepEqual(assistant.parts[0].provider_metadata, {
382
+ openai: { itemId: "text_end" },
383
+ });
384
+ assert.deepEqual(assistant.parts[1].provider_metadata, {
385
+ openai: { itemId: "reasoning_delta" },
386
+ });
387
+ assert.equal(assistant.parts[2].url, "https://example.com/new");
388
+ assert.equal(assistant.parts[2].title, "Updated source");
389
+ assert.deepEqual(assistant.parts[3].data, { percent: 90 });
390
+ const tool_part = assistant.parts[4];
391
+ assert.equal(tool_part.state, "failed");
392
+ assert.equal(tool_part.title, "Lookup");
393
+ assert.equal(tool_part.dynamic, true);
394
+ assert.deepEqual(tool_part.tool_metadata, { category: "search" });
395
+ assert.deepEqual(tool_part.approval, {
396
+ approval_id: "approval_stream_1",
397
+ approved: false,
398
+ });
399
+ assert.equal(
400
+ assistant.parts.some((part) => part.type === "data" && part.data_id === "transient_1"),
401
+ false,
402
+ );
403
+ });
404
+
284
405
  test("UI Tool Provider metadata 经 Session roundtrip 后恢复为 ModelMessage providerOptions", async () => {
285
406
  const parts = from_ui_assistant_parts([{
286
407
  type: "dynamic-tool",
@@ -388,6 +509,171 @@ test("UI Tool Provider metadata 经 Session roundtrip 后恢复为 ModelMessage
388
509
  });
389
510
  });
390
511
 
512
+ test("SessionMessage 无损恢复 AI SDK UIMessage 的完整 Part 语义", () => {
513
+ const user_parts = [
514
+ {
515
+ type: "text",
516
+ text: "分析附件",
517
+ state: "done",
518
+ providerMetadata: { openai: { itemId: "user_text_1" } },
519
+ },
520
+ {
521
+ type: "file",
522
+ mediaType: "image/png",
523
+ url: "https://example.com/input.png",
524
+ filename: "input.png",
525
+ providerMetadata: { openai: { fileId: "file_1" } },
526
+ },
527
+ {
528
+ type: "data-preferences",
529
+ id: "preferences_1",
530
+ data: { locale: "zh-CN" },
531
+ },
532
+ ];
533
+ const restored_user = to_executor_ui_message({
534
+ message_id: "user_roundtrip",
535
+ session_id: "session_roundtrip",
536
+ turn_id: "turn_roundtrip",
537
+ sequence: 1,
538
+ revision: 1,
539
+ visibility: "visible",
540
+ created_at: 1,
541
+ updated_at: 1,
542
+ type: "user",
543
+ input_type: "prompt",
544
+ parts: from_ui_user_parts(user_parts),
545
+ });
546
+ assert.deepEqual(restored_user.parts, user_parts);
547
+
548
+ const assistant_parts = [
549
+ {
550
+ type: "text",
551
+ text: "正在处理",
552
+ state: "streaming",
553
+ providerMetadata: { openai: { itemId: "msg_1" } },
554
+ },
555
+ {
556
+ type: "reasoning",
557
+ text: "先读取来源",
558
+ state: "done",
559
+ providerMetadata: { openai: { itemId: "reasoning_1" } },
560
+ },
561
+ {
562
+ type: "file",
563
+ mediaType: "application/pdf",
564
+ url: "https://example.com/report.pdf",
565
+ filename: "report.pdf",
566
+ providerMetadata: { openai: { fileId: "file_2" } },
567
+ },
568
+ {
569
+ type: "source-url",
570
+ sourceId: "source_url_1",
571
+ url: "https://example.com/source",
572
+ title: "Example source",
573
+ providerMetadata: { openai: { itemId: "source_1" } },
574
+ },
575
+ {
576
+ type: "source-document",
577
+ sourceId: "source_document_1",
578
+ mediaType: "application/pdf",
579
+ title: "Source document",
580
+ filename: "source.pdf",
581
+ providerMetadata: { openai: { itemId: "source_2" } },
582
+ },
583
+ { type: "step-start" },
584
+ {
585
+ type: "data-progress",
586
+ id: "progress_1",
587
+ data: { percent: 80 },
588
+ },
589
+ {
590
+ type: "dynamic-tool",
591
+ toolName: "lookup",
592
+ toolCallId: "call_streaming",
593
+ state: "input-streaming",
594
+ title: "Lookup",
595
+ toolMetadata: { category: "search" },
596
+ providerExecuted: false,
597
+ callProviderMetadata: { openai: { itemId: "fc_streaming" } },
598
+ },
599
+ {
600
+ type: "tool-fetch",
601
+ toolCallId: "call_ready",
602
+ state: "input-available",
603
+ input: { url: "https://example.com" },
604
+ title: "Fetch",
605
+ toolMetadata: { category: "network" },
606
+ providerExecuted: true,
607
+ callProviderMetadata: { openai: { itemId: "fc_ready" } },
608
+ },
609
+ {
610
+ type: "dynamic-tool",
611
+ toolName: "shell_exec",
612
+ toolCallId: "call_approval",
613
+ state: "approval-requested",
614
+ input: { cmd: "pwd" },
615
+ approval: { id: "approval_1" },
616
+ },
617
+ {
618
+ type: "tool-shell_exec",
619
+ toolCallId: "call_responded",
620
+ state: "approval-responded",
621
+ input: { cmd: "ls" },
622
+ approval: { id: "approval_2", approved: true, reason: "Allowed" },
623
+ },
624
+ {
625
+ type: "dynamic-tool",
626
+ toolName: "lookup",
627
+ toolCallId: "call_completed",
628
+ state: "output-available",
629
+ input: { q: "downcity" },
630
+ output: { count: 1 },
631
+ preliminary: true,
632
+ approval: { id: "approval_3", approved: true, reason: "Allowed" },
633
+ callProviderMetadata: { openai: { itemId: "fc_completed" } },
634
+ resultProviderMetadata: { openai: { itemId: "result_completed" } },
635
+ },
636
+ {
637
+ type: "dynamic-tool",
638
+ toolName: "parse",
639
+ toolCallId: "call_error",
640
+ state: "output-error",
641
+ input: undefined,
642
+ rawInput: "{invalid",
643
+ errorText: "Invalid input",
644
+ approval: { id: "approval_4", approved: true },
645
+ resultProviderMetadata: { openai: { itemId: "result_error" } },
646
+ },
647
+ {
648
+ type: "dynamic-tool",
649
+ toolName: "delete",
650
+ toolCallId: "call_denied",
651
+ state: "output-denied",
652
+ input: { path: "/tmp/a" },
653
+ approval: { id: "approval_5", approved: false, reason: "Denied" },
654
+ },
655
+ ];
656
+ const canonical_parts = from_ui_assistant_parts(assistant_parts);
657
+ const restored_assistant = to_executor_ui_message({
658
+ message_id: "assistant_roundtrip",
659
+ session_id: "session_roundtrip",
660
+ turn_id: "turn_roundtrip",
661
+ sequence: 2,
662
+ revision: 1,
663
+ visibility: "visible",
664
+ created_at: 2,
665
+ updated_at: 2,
666
+ type: "assistant",
667
+ kind: "normal",
668
+ segment_index: 1,
669
+ status: "streaming",
670
+ parts: canonical_parts,
671
+ });
672
+ assert.deepEqual(restored_assistant.parts, assistant_parts);
673
+ assert.equal(canonical_parts.find((part) => part.tool_call_id === "call_ready").dynamic, false);
674
+ assert.equal(canonical_parts.find((part) => part.tool_call_id === "call_completed").dynamic, true);
675
+ });
676
+
391
677
  test("Approval Broker 只接受已经准备完整输入的 Tool", async () => {
392
678
  const { recorder, events } = await create_recorder("tool-ready-barrier-test");
393
679
  const approval_broker = new SessionApprovalBroker({
@@ -430,7 +716,7 @@ test("Approval Broker 只接受已经准备完整输入的 Tool", async () => {
430
716
  const tool = recorder.get_message(writer.message_id).parts[0];
431
717
  assert.equal(tool.state, "approval-required");
432
718
  assert.equal(tool.approval.approval_id, approval_handle.approval_id);
433
- assert.equal(tool.approval.command, approval_input.command);
719
+ assert.equal(tool.approval.request.command, approval_input.command);
434
720
  assert.equal(tool.input.cmd, "ls -la /Users/example/Desktop");
435
721
  await approval_broker.resolve({
436
722
  approval_id: approval_handle.approval_id,
@@ -154,9 +154,9 @@ test("unrestricted Shell 审批保留当前 Turn 并等待用户决定", async (
154
154
  .flatMap((message) => message.type === "assistant" ? message.parts : [])
155
155
  .find((part) => part.type === "tool" && part.tool_call_id === "call_unrestricted");
156
156
  assert.ok(approval_snapshot, JSON.stringify(messages.items));
157
- assert.equal(approval_snapshot.session_id, session.id);
158
- assert.equal(approval_snapshot.turn_id, turn.id);
159
- assert.equal(approval_snapshot.tool_call_id, "call_unrestricted");
157
+ assert.equal(approval_snapshot.request.session_id, session.id);
158
+ assert.equal(approval_snapshot.request.turn_id, turn.id);
159
+ assert.equal(approval_snapshot.request.tool_call_id, "call_unrestricted");
160
160
  assert.deepEqual(await approval_result, {
161
161
  success: true,
162
162
  approval_id: approval_snapshot.approval_id,
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @file 验证 Turn 失败只写入一次结构化错误,并保留已经生成的 Assistant 内容。
3
+ */
4
+
5
+ import assert from "node:assert/strict";
6
+ import fs from "node:fs/promises";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import test from "node:test";
10
+
11
+ import { SessionApprovalBroker } from "../bin/session/approval/SessionApprovalBroker.js";
12
+ import { JsonlSessionMessageStore } from "../bin/session/messages/JsonlSessionMessageStore.js";
13
+ import { SessionMessages } from "../bin/session/SessionMessages.js";
14
+ import { SessionEventHub } from "../bin/session/runtime/SessionEventHub.js";
15
+ import { SessionTurn } from "../bin/session/SessionTurn.js";
16
+
17
+ async function create_turn_harness(execute_run) {
18
+ const session_id = "session-turn-failure-test";
19
+ const root_path = await fs.mkdtemp(path.join(os.tmpdir(), "downcity-turn-failure-"));
20
+ const messages = new SessionMessages({
21
+ session_id,
22
+ store: new JsonlSessionMessageStore({
23
+ session_id,
24
+ file_path: path.join(root_path, "active.jsonl"),
25
+ }),
26
+ publish: () => {},
27
+ });
28
+ await messages.initialize();
29
+
30
+ const turn = new SessionTurn({
31
+ session_id,
32
+ project_root: root_path,
33
+ executor: {
34
+ run: async ({ runContext }) => await execute_run(runContext),
35
+ stop: () => false,
36
+ compact_history: async () => ({ compacted: false, reason: "nothing_to_compact" }),
37
+ },
38
+ state: {
39
+ ensure_runnable: async () => {},
40
+ ensure_title_from_history: async () => {},
41
+ touch_metadata: async () => {},
42
+ },
43
+ messages,
44
+ events: new SessionEventHub(),
45
+ approvals: new SessionApprovalBroker({ session_id, messages }),
46
+ apply_command: async () => {},
47
+ });
48
+
49
+ return { messages, turn };
50
+ }
51
+
52
+ test("Provider 在输出前失败时只持久化 Error Message", async () => {
53
+ const { messages, turn } = await create_turn_harness(async () => ({
54
+ success: false,
55
+ error: "quota exceeded",
56
+ deferredPersistedUserMessages: [],
57
+ }));
58
+
59
+ const handle = await turn.prompt({ query: "hello" });
60
+ const result = await handle.finished;
61
+ const page = await messages.list_messages();
62
+
63
+ assert.equal(result.success, false);
64
+ assert.equal(result.error, "quota exceeded");
65
+ assert.equal(result.assistantMessage, undefined);
66
+ assert.deepEqual(page.items.map((message) => message.type), ["user", "error"]);
67
+ assert.equal(page.items[1].code, "turn_execution_failed");
68
+ assert.equal(page.items[1].message, "quota exceeded");
69
+ });
70
+
71
+ test("Provider 在部分输出后失败时保留 failed Assistant 并追加 Error Message", async () => {
72
+ const { messages, turn } = await create_turn_harness(async (run_context) => {
73
+ await run_context.onUiMessageChunkCallback({ type: "text-start", id: "text-1" });
74
+ await run_context.onUiMessageChunkCallback({
75
+ type: "text-delta",
76
+ id: "text-1",
77
+ delta: "partial response",
78
+ });
79
+ await run_context.onUiMessageChunkCallback({ type: "text-end", id: "text-1" });
80
+ return {
81
+ success: false,
82
+ error: "stream interrupted",
83
+ deferredPersistedUserMessages: [],
84
+ };
85
+ });
86
+
87
+ const handle = await turn.prompt({ query: "hello" });
88
+ const result = await handle.finished;
89
+ const page = await messages.list_messages();
90
+
91
+ assert.equal(result.success, false);
92
+ assert.equal(result.assistantMessage, undefined);
93
+ assert.deepEqual(page.items.map((message) => message.type), [
94
+ "user",
95
+ "assistant",
96
+ "error",
97
+ ]);
98
+ assert.equal(page.items[1].status, "failed");
99
+ assert.equal(page.items[1].parts[0].text, "partial response");
100
+ assert.equal(page.items[2].message, "stream interrupted");
101
+ });
@@ -601,10 +601,6 @@ export class CoreEngineRunner {
601
601
  return {
602
602
  success: false,
603
603
  error: error_text,
604
- assistantMessage: build_fallback_assistant_message(
605
- session_id,
606
- `Execution failed: ${error_text}`,
607
- ),
608
604
  ...(compact_required ? { compact_required: true } : {}),
609
605
  deferredPersistedUserMessages: [
610
606
  ...input.run_context.deferredPersistedUserMessages,
@@ -635,7 +631,7 @@ function build_internal_user_message(input: {
635
631
  };
636
632
  }
637
633
 
638
- /** 构造 Executor 失败或空结果使用的临时 Assistant Message。 */
634
+ /** 构造成功执行但缺少最终消息时使用的临时 Assistant Message。 */
639
635
  function build_fallback_assistant_message(
640
636
  session_id: string,
641
637
  text: string,
@@ -107,16 +107,15 @@ interface ExecutorRecoveryRunInput {
107
107
  * 执行恢复与重试策略服务。
108
108
  */
109
109
  export class ExecutorRecoveryPolicy {
110
- private readonly session_id: string;
111
110
  private readonly should_compact: ExecutorRecoveryPolicyOptions["should_compact"];
112
111
  private readonly logger: Logger;
113
112
  private retry_count = 0;
114
113
 
115
114
  constructor(options: ExecutorRecoveryPolicyOptions) {
116
- this.session_id = String(options.session_id || "").trim();
115
+ const session_id = String(options.session_id || "").trim();
117
116
  this.should_compact = options.should_compact;
118
117
  this.logger = options.logger;
119
- if (!this.session_id) {
118
+ if (!session_id) {
120
119
  throw new Error("ExecutorRecoveryPolicy requires a non-empty session_id");
121
120
  }
122
121
  }
@@ -161,8 +160,6 @@ export class ExecutorRecoveryPolicy {
161
160
  return this.build_failure_result({
162
161
  error_text:
163
162
  "Context length exceeded and retries failed. Please resend your question.",
164
- fallback_text:
165
- "Context length exceeded and retries failed. Please resend your question.",
166
163
  run_context: input.run_context,
167
164
  });
168
165
  }
@@ -173,7 +170,6 @@ export class ExecutorRecoveryPolicy {
173
170
  });
174
171
  return this.build_failure_result({
175
172
  error_text,
176
- fallback_text: `Execution failed: ${error_text}`,
177
173
  run_context: input.run_context,
178
174
  });
179
175
  }
@@ -185,11 +181,6 @@ export class ExecutorRecoveryPolicy {
185
181
  */
186
182
  error_text: string;
187
183
 
188
- /**
189
- * fallback assistant 消息文本。
190
- */
191
- fallback_text: string;
192
-
193
184
  /**
194
185
  * 当前显式运行上下文。
195
186
  */
@@ -198,18 +189,6 @@ export class ExecutorRecoveryPolicy {
198
189
  return {
199
190
  success: false,
200
191
  error: input.error_text,
201
- assistantMessage: {
202
- id: `a:${this.session_id}:${Date.now()}`,
203
- role: "assistant",
204
- metadata: {
205
- v: 1,
206
- ts: Date.now(),
207
- sessionId: this.session_id,
208
- source: "egress",
209
- kind: "normal",
210
- },
211
- parts: [{ type: "text", text: input.fallback_text }],
212
- },
213
192
  deferredPersistedUserMessages: [
214
193
  ...input.run_context.deferredPersistedUserMessages,
215
194
  ],