@downcity/agent 1.1.270 → 1.1.276

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 (35) hide show
  1. package/bin/executor/core-engine/CoreEngineRunner.d.ts.map +1 -1
  2. package/bin/executor/core-engine/CoreEngineRunner.js +18 -0
  3. package/bin/executor/core-engine/CoreEngineRunner.js.map +1 -1
  4. package/bin/executor/core-engine/CoreEngineUiStreamCollector.d.ts +1 -1
  5. package/bin/executor/core-engine/CoreEngineUiStreamCollector.d.ts.map +1 -1
  6. package/bin/executor/core-engine/CoreEngineUiStreamCollector.js +6 -4
  7. package/bin/executor/core-engine/CoreEngineUiStreamCollector.js.map +1 -1
  8. package/bin/executor/types/SessionRun.d.ts +14 -1
  9. package/bin/executor/types/SessionRun.d.ts.map +1 -1
  10. package/bin/session/SessionMessages.d.ts +2 -0
  11. package/bin/session/SessionMessages.d.ts.map +1 -1
  12. package/bin/session/SessionMessages.js +21 -0
  13. package/bin/session/SessionMessages.js.map +1 -1
  14. package/bin/session/SessionTurn.d.ts.map +1 -1
  15. package/bin/session/SessionTurn.js +20 -7
  16. package/bin/session/SessionTurn.js.map +1 -1
  17. package/bin/session/messages/SessionAssistantMessageWriter.d.ts +21 -8
  18. package/bin/session/messages/SessionAssistantMessageWriter.d.ts.map +1 -1
  19. package/bin/session/messages/SessionAssistantMessageWriter.js +116 -23
  20. package/bin/session/messages/SessionAssistantMessageWriter.js.map +1 -1
  21. package/bin/types/executor/SessionRunContext.d.ts +21 -1
  22. package/bin/types/executor/SessionRunContext.d.ts.map +1 -1
  23. package/package.json +3 -3
  24. package/scripts/core-engine-ui-stream-collector.test.mjs +38 -0
  25. package/scripts/executor-failure-result.test.mjs +104 -1
  26. package/scripts/session-messages.test.mjs +161 -2
  27. package/scripts/session-turn-failure.test.mjs +108 -1
  28. package/src/executor/core-engine/CoreEngineRunner.ts +23 -0
  29. package/src/executor/core-engine/CoreEngineUiStreamCollector.ts +6 -4
  30. package/src/executor/types/SessionRun.ts +20 -1
  31. package/src/session/SessionMessages.ts +31 -0
  32. package/src/session/SessionTurn.ts +19 -7
  33. package/src/session/messages/SessionAssistantMessageWriter.ts +143 -26
  34. package/src/types/executor/SessionRunContext.ts +26 -0
  35. package/tsconfig.tsbuildinfo +1 -1
@@ -230,6 +230,7 @@ test("Streaming Tool Provider metadata 在输入和输出状态间完整保留",
230
230
  turn_id: "turn-provider-metadata",
231
231
  segment_index: 1,
232
232
  });
233
+ await writer.begin_step();
233
234
  await writer.apply_chunk({
234
235
  type: "tool-input-start",
235
236
  toolCallId: "call_1",
@@ -256,7 +257,7 @@ test("Streaming Tool Provider metadata 在输入和输出状态间完整保留",
256
257
  providerExecuted: true,
257
258
  providerMetadata: { openai: { resultId: "result_1" } },
258
259
  });
259
- await writer.reconcile_final_tool_part({
260
+ await writer.finish_step([{
260
261
  part_id: "call_1",
261
262
  sequence: 1,
262
263
  type: "tool",
@@ -267,7 +268,7 @@ test("Streaming Tool Provider metadata 在输入和输出状态间完整保留",
267
268
  output: "ok",
268
269
  provider_executed: true,
269
270
  call_provider_metadata: { openai: { itemId: "fc_final" } },
270
- });
271
+ }]);
271
272
  await writer.complete();
272
273
 
273
274
  const assistant = (await read_jsonl(file_path))[0];
@@ -820,6 +821,7 @@ test("不同模型 step 重复使用 Text chunk ID 时仍保持真实 Part 顺
820
821
  const { recorder, file_path } = await create_recorder("reused-text-id-order-test");
821
822
  const writer = await recorder.open_assistant_message({ turn_id: "turn-1", segment_index: 1 });
822
823
 
824
+ await writer.begin_step();
823
825
  await writer.apply_chunk({ type: "text-start", id: "txt-0" });
824
826
  await writer.apply_chunk({ type: "text-delta", id: "txt-0", delta: "first" });
825
827
  await writer.apply_chunk({ type: "text-end", id: "txt-0" });
@@ -831,7 +833,27 @@ test("不同模型 step 重复使用 Text chunk ID 时仍保持真实 Part 顺
831
833
  input: { query: "first" },
832
834
  });
833
835
  await writer.apply_chunk({ type: "tool-output-available", toolCallId: "call-1", output: "one" });
836
+ await writer.finish_step([
837
+ {
838
+ part_id: "text-1",
839
+ sequence: 1,
840
+ type: "text",
841
+ text: "first",
842
+ state: "done",
843
+ },
844
+ {
845
+ part_id: "call-1",
846
+ sequence: 2,
847
+ type: "tool",
848
+ tool_call_id: "call-1",
849
+ tool_name: "search",
850
+ state: "completed",
851
+ input: { query: "first" },
852
+ output: "one",
853
+ },
854
+ ]);
834
855
 
856
+ await writer.begin_step();
835
857
  await writer.apply_chunk({ type: "text-start", id: "txt-0" });
836
858
  await writer.apply_chunk({ type: "text-delta", id: "txt-0", delta: "second" });
837
859
  await writer.apply_chunk({ type: "text-end", id: "txt-0" });
@@ -843,6 +865,25 @@ test("不同模型 step 重复使用 Text chunk ID 时仍保持真实 Part 顺
843
865
  input: { query: "second" },
844
866
  });
845
867
  await writer.apply_chunk({ type: "tool-output-available", toolCallId: "call-2", output: "two" });
868
+ await writer.finish_step([
869
+ {
870
+ part_id: "text-1",
871
+ sequence: 1,
872
+ type: "text",
873
+ text: "second",
874
+ state: "done",
875
+ },
876
+ {
877
+ part_id: "call-2",
878
+ sequence: 2,
879
+ type: "tool",
880
+ tool_call_id: "call-2",
881
+ tool_name: "search",
882
+ state: "completed",
883
+ input: { query: "second" },
884
+ output: "two",
885
+ },
886
+ ]);
846
887
  await writer.complete();
847
888
 
848
889
  const assistant = (await read_jsonl(file_path))[0];
@@ -855,10 +896,58 @@ test("不同模型 step 重复使用 Text chunk ID 时仍保持真实 Part 顺
855
896
  assert.notEqual(assistant.parts[0].part_id, assistant.parts[2].part_id);
856
897
  });
857
898
 
899
+ test("不同模型 step 重复 Source 与 Data ID 时创建独立 canonical Parts", async () => {
900
+ const { recorder, file_path } = await create_recorder("reused-structured-id-order-test");
901
+ const writer = await recorder.open_assistant_message({ turn_id: "turn-1", segment_index: 1 });
902
+
903
+ for (const step of [1, 2]) {
904
+ await writer.begin_step();
905
+ await writer.apply_chunk({
906
+ type: "source-url",
907
+ sourceId: "source-1",
908
+ url: `https://example.com/${step}`,
909
+ });
910
+ await writer.apply_chunk({
911
+ type: "data-status",
912
+ id: "status-1",
913
+ data: { step },
914
+ });
915
+ await writer.finish_step([
916
+ {
917
+ part_id: "source-1",
918
+ sequence: 1,
919
+ type: "source",
920
+ source_type: "url",
921
+ source_id: "source-1",
922
+ url: `https://example.com/${step}`,
923
+ },
924
+ {
925
+ part_id: "data-1",
926
+ sequence: 2,
927
+ type: "data",
928
+ data_type: "data-status",
929
+ data: { step },
930
+ data_id: "status-1",
931
+ },
932
+ ]);
933
+ }
934
+ await writer.complete();
935
+
936
+ const assistant = (await read_jsonl(file_path))[0];
937
+ assert.deepEqual(
938
+ assistant.parts.map((part) => part.type),
939
+ ["source", "data", "source", "data"],
940
+ );
941
+ assert.deepEqual(assistant.parts.map((part) => part.sequence), [1, 2, 3, 4]);
942
+ assert.notEqual(assistant.parts[0].part_id, assistant.parts[2].part_id);
943
+ assert.notEqual(assistant.parts[1].part_id, assistant.parts[3].part_id);
944
+ });
945
+
858
946
  test("空 Text Start 不会抢占后续 Tool 的真实顺序", async () => {
859
947
  const { recorder, events, file_path } = await create_recorder("deferred-text-order-test");
860
948
  const writer = await recorder.open_assistant_message({ turn_id: "turn-1", segment_index: 1 });
861
949
 
950
+ await writer.begin_step();
862
951
  await writer.apply_chunk({ type: "reasoning-start", id: "reasoning-0" });
863
952
  await writer.apply_chunk({ type: "reasoning-delta", id: "reasoning-0", delta: "先执行命令" });
864
953
  await writer.apply_chunk({ type: "reasoning-end", id: "reasoning-0" });
@@ -881,6 +970,32 @@ test("空 Text Start 不会抢占后续 Tool 的真实顺序", async () => {
881
970
  });
882
971
  await writer.apply_chunk({ type: "text-delta", id: "text-0", delta: "命令执行完成" });
883
972
  await writer.apply_chunk({ type: "text-end", id: "text-0" });
973
+ await writer.finish_step([
974
+ {
975
+ part_id: "reasoning-1",
976
+ sequence: 1,
977
+ type: "reasoning",
978
+ text: "先执行命令",
979
+ state: "done",
980
+ },
981
+ {
982
+ part_id: "call-1",
983
+ sequence: 2,
984
+ type: "tool",
985
+ tool_call_id: "call-1",
986
+ tool_name: "shell_exec",
987
+ state: "completed",
988
+ input: { cmd: "pwd" },
989
+ output: { success: true },
990
+ },
991
+ {
992
+ part_id: "text-1",
993
+ sequence: 3,
994
+ type: "text",
995
+ text: "命令执行完成",
996
+ state: "done",
997
+ },
998
+ ]);
884
999
  await writer.complete();
885
1000
 
886
1001
  const assistant = (await read_jsonl(file_path))[0];
@@ -902,6 +1017,50 @@ test("空 Text Start 不会抢占后续 Tool 的真实顺序", async () => {
902
1017
  );
903
1018
  });
904
1019
 
1020
+ test("step 最终快照缺少 canonical Tool chunk 时拒绝猜测顺序", async () => {
1021
+ const { recorder, events, file_path } = await create_recorder("final-reconcile-order-test");
1022
+ const writer = await recorder.open_assistant_message({ turn_id: "turn-1", segment_index: 1 });
1023
+
1024
+ await writer.begin_step();
1025
+ await writer.apply_chunk({ type: "text-start", id: "text-0" });
1026
+ await writer.apply_chunk({ type: "text-delta", id: "text-0", delta: "最终结论" });
1027
+ await writer.apply_chunk({ type: "text-end", id: "text-0" });
1028
+ const streamed_text_part_id = events.find(
1029
+ (event) => event.variant === "part" && event.type === "text",
1030
+ ).part.part_id;
1031
+
1032
+ await assert.rejects(
1033
+ writer.finish_step([
1034
+ {
1035
+ part_id: "call-1",
1036
+ sequence: 1,
1037
+ type: "tool",
1038
+ tool_call_id: "call-1",
1039
+ tool_name: "shell_exec",
1040
+ state: "completed",
1041
+ input: { cmd: "pwd" },
1042
+ output: { success: true },
1043
+ },
1044
+ {
1045
+ part_id: "text-1",
1046
+ sequence: 2,
1047
+ type: "text",
1048
+ text: "最终结论",
1049
+ state: "done",
1050
+ },
1051
+ ]),
1052
+ /snapshot mismatch: part count 1 != 2/,
1053
+ );
1054
+ await writer.abort_step();
1055
+ await writer.fail("canonical snapshot mismatch");
1056
+
1057
+ const assistant = (await read_jsonl(file_path))[0];
1058
+ assert.equal(assistant.status, "failed");
1059
+ assert.deepEqual(assistant.parts.map((part) => part.type), ["text"]);
1060
+ assert.deepEqual(assistant.parts.map((part) => part.sequence), [1]);
1061
+ assert.equal(assistant.parts[0].part_id, streamed_text_part_id);
1062
+ });
1063
+
905
1064
  test("重启时将 Assistant 草稿收口为 stopped,并将运行中 Action 标记失败", async () => {
906
1065
  const { recorder, file_path, assistant_message_file_path } = await create_recorder("recovery-test");
907
1066
  await recorder.append_user_message({
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @file 验证 Turn 失败只写入一次结构化错误,并保留已经生成的 Assistant 内容。
2
+ * @file 验证 Turn 失败收口与最终 Assistant 快照顺序。
3
3
  */
4
4
 
5
5
  import assert from "node:assert/strict";
@@ -99,3 +99,110 @@ test("Provider 在部分输出后失败时保留 failed Assistant 并追加 Erro
99
99
  assert.equal(page.items[1].parts[0].text, "partial response");
100
100
  assert.equal(page.items[2].message, "stream interrupted");
101
101
  });
102
+
103
+ test("Turn 使用 step canonical chunks 保持 Tool 与最终正文顺序", async () => {
104
+ const { messages, turn } = await create_turn_harness(async (run_context) => {
105
+ await run_context.on_ui_message_step_start();
106
+ await run_context.onUiMessageChunkCallback({
107
+ type: "tool-input-start",
108
+ toolCallId: "call-1",
109
+ toolName: "shell_exec",
110
+ });
111
+ await run_context.onUiMessageChunkCallback({
112
+ type: "tool-input-available",
113
+ toolCallId: "call-1",
114
+ toolName: "shell_exec",
115
+ input: { cmd: "pwd" },
116
+ });
117
+ await run_context.onUiMessageChunkCallback({
118
+ type: "tool-output-available",
119
+ toolCallId: "call-1",
120
+ output: { success: true },
121
+ });
122
+ await run_context.onUiMessageChunkCallback({ type: "text-start", id: "text-1" });
123
+ await run_context.onUiMessageChunkCallback({
124
+ type: "text-delta",
125
+ id: "text-1",
126
+ delta: "最终结论",
127
+ });
128
+ await run_context.onUiMessageChunkCallback({ type: "text-end", id: "text-1" });
129
+ const assistant_message = {
130
+ id: "assistant-1",
131
+ role: "assistant",
132
+ parts: [
133
+ {
134
+ type: "dynamic-tool",
135
+ toolCallId: "call-1",
136
+ toolName: "shell_exec",
137
+ state: "output-available",
138
+ input: { cmd: "pwd" },
139
+ output: { success: true },
140
+ },
141
+ { type: "text", text: "最终结论", state: "done" },
142
+ ],
143
+ };
144
+ await run_context.on_ui_message_step_finish(assistant_message);
145
+ return {
146
+ success: true,
147
+ assistantMessage: assistant_message,
148
+ deferredPersistedUserMessages: [],
149
+ };
150
+ });
151
+
152
+ const handle = await turn.prompt({ query: "diagnose" });
153
+ const result = await handle.finished;
154
+ const page = await messages.list_messages();
155
+ const assistant = page.items.find((message) => message.type === "assistant");
156
+
157
+ assert.equal(result.success, true);
158
+ assert.deepEqual(assistant.parts.map((part) => part.type), ["tool", "text"]);
159
+ assert.deepEqual(assistant.parts.map((part) => part.sequence), [1, 2]);
160
+ });
161
+
162
+ test("Turn 在 step 最终快照出现未流式写入的 Tool 时失败", async () => {
163
+ const { messages, turn } = await create_turn_harness(async (run_context) => {
164
+ await run_context.on_ui_message_step_start();
165
+ await run_context.onUiMessageChunkCallback({ type: "text-start", id: "text-1" });
166
+ await run_context.onUiMessageChunkCallback({
167
+ type: "text-delta",
168
+ id: "text-1",
169
+ delta: "最终结论",
170
+ });
171
+ await run_context.onUiMessageChunkCallback({ type: "text-end", id: "text-1" });
172
+ try {
173
+ await run_context.on_ui_message_step_finish({
174
+ id: "assistant-1",
175
+ role: "assistant",
176
+ parts: [
177
+ {
178
+ type: "dynamic-tool",
179
+ toolCallId: "call-1",
180
+ toolName: "shell_exec",
181
+ state: "output-available",
182
+ input: { cmd: "pwd" },
183
+ output: { success: true },
184
+ },
185
+ { type: "text", text: "最终结论", state: "done" },
186
+ ],
187
+ });
188
+ } catch (error) {
189
+ await run_context.on_ui_message_step_abort();
190
+ return {
191
+ success: false,
192
+ error: error.message,
193
+ deferredPersistedUserMessages: [],
194
+ };
195
+ }
196
+ });
197
+
198
+ const handle = await turn.prompt({ query: "diagnose" });
199
+ const result = await handle.finished;
200
+ const page = await messages.list_messages();
201
+ const assistant = page.items.find((message) => message.type === "assistant");
202
+
203
+ assert.equal(result.success, false);
204
+ assert.match(result.error, /snapshot mismatch/);
205
+ assert.equal(assistant.status, "failed");
206
+ assert.deepEqual(assistant.parts.map((part) => part.type), ["text"]);
207
+ assert.equal(page.items.at(-1).type, "error");
208
+ });
@@ -298,7 +298,13 @@ export class CoreEngineRunner {
298
298
  last_observed_stream_error = undefined;
299
299
  let step_assistant_ui_message: SessionMessageRecordV1;
300
300
  let executed_steps: StepResult<Record<string, Tool>>[];
301
+ let canonical_step_started = false;
302
+ let canonical_step_finished = false;
301
303
  try {
304
+ if (input.run_context.on_ui_message_step_start) {
305
+ await input.run_context.on_ui_message_step_start();
306
+ canonical_step_started = true;
307
+ }
302
308
  const result = streamText({
303
309
  model: step_inputs.model,
304
310
  system,
@@ -326,6 +332,13 @@ export class CoreEngineRunner {
326
332
  abortSignal: input.run_context.abortSignal,
327
333
  });
328
334
 
335
+ if (input.run_context.on_ui_message_step_finish) {
336
+ await input.run_context.on_ui_message_step_finish(
337
+ step_assistant_ui_message,
338
+ );
339
+ }
340
+ canonical_step_finished = true;
341
+
329
342
  final_assistant_ui_message = mergeAssistantUiMessages(
330
343
  final_assistant_ui_message,
331
344
  step_assistant_ui_message,
@@ -336,6 +349,13 @@ export class CoreEngineRunner {
336
349
  message_state.appendRuntimeSessionMessage(step_assistant_ui_message);
337
350
  executed_steps = await result.steps;
338
351
  } catch (error) {
352
+ if (
353
+ canonical_step_started &&
354
+ !canonical_step_finished &&
355
+ input.run_context.on_ui_message_step_abort
356
+ ) {
357
+ await input.run_context.on_ui_message_step_abort();
358
+ }
339
359
  const compact_error = this.should_compact_on_error(error)
340
360
  ? error
341
361
  : last_observed_stream_error;
@@ -557,6 +577,7 @@ export class CoreEngineRunner {
557
577
  return {
558
578
  success: true,
559
579
  assistantMessage: final_message,
580
+ assistant_file_parts: [...input.run_context.pendingAssistantFileParts],
560
581
  ...(compact_required ? { compact_required: true } : {}),
561
582
  deferredPersistedUserMessages: [
562
583
  ...input.run_context.deferredPersistedUserMessages,
@@ -578,6 +599,7 @@ export class CoreEngineRunner {
578
599
  success: false,
579
600
  error: error_text,
580
601
  ...(stopped_message ? { assistantMessage: stopped_message } : {}),
602
+ assistant_file_parts: [...input.run_context.pendingAssistantFileParts],
581
603
  ...(compact_required ? { compact_required: true } : {}),
582
604
  deferredPersistedUserMessages: [
583
605
  ...input.run_context.deferredPersistedUserMessages,
@@ -601,6 +623,7 @@ export class CoreEngineRunner {
601
623
  return {
602
624
  success: false,
603
625
  error: error_text,
626
+ assistant_file_parts: [...input.run_context.pendingAssistantFileParts],
604
627
  ...(compact_required ? { compact_required: true } : {}),
605
628
  deferredPersistedUserMessages: [
606
629
  ...input.run_context.deferredPersistedUserMessages,
@@ -4,7 +4,7 @@
4
4
  * 关键点(中文)
5
5
  * - 必须完整消费 AI SDK UI stream,才能稳定触发 `onFinish`。
6
6
  * - 优先使用结构化 `responseMessage`;缺失时才回退到纯文本。
7
- * - UI chunk 回调是展示层副作用,失败不应阻断 session 执行。
7
+ * - UI chunk 回调负责上层 canonical message 写入,失败必须终止本轮收敛。
8
8
  */
9
9
 
10
10
  import type { streamText } from "ai";
@@ -54,6 +54,7 @@ export async function collectFinalAssistantMessageFromUiStream(params: {
54
54
  let streamedAssistantMessage: SessionMessageRecordV1 | null = null;
55
55
  let uiFinishSummary: JsonObject | null = null;
56
56
  let streamed_text = "";
57
+ let callback_failed = false;
57
58
 
58
59
  const uiStream = params.result.toUIMessageStream<SessionMessageRecordV1>({
59
60
  // 关键点(中文):SDK stream 需要 reasoning 旁路事件时可直接消费;最终落盘仍由 responseMessage 收敛。
@@ -91,12 +92,13 @@ export async function collectFinalAssistantMessageFromUiStream(params: {
91
92
  if (typeof params.onUiMessageChunkCallback !== "function") continue;
92
93
  try {
93
94
  await params.onUiMessageChunkCallback(chunk);
94
- } catch {
95
- // ignore UI stream callback failures
95
+ } catch (error) {
96
+ callback_failed = true;
97
+ throw error;
96
98
  }
97
99
  }
98
100
  } catch (error) {
99
- if (!params.abortSignal?.aborted) {
101
+ if (callback_failed || !params.abortSignal?.aborted) {
100
102
  throw error;
101
103
  }
102
104
  }
@@ -7,7 +7,7 @@
7
7
  * - 输出暴露可选 assistantMessage(UIMessage)。
8
8
  */
9
9
 
10
- import type { Tool, UIMessageChunk } from "ai";
10
+ import type { FileUIPart, Tool, UIMessageChunk } from "ai";
11
11
  import type {
12
12
  SessionRecordV1,
13
13
  SessionMessageRecordV1,
@@ -81,6 +81,17 @@ export type SessionUiMessageChunkCallback = (
81
81
  chunk: SessionUiMessageChunk,
82
82
  ) => Promise<void>;
83
83
 
84
+ /** 单个模型 UI stream 开始前的 canonical step 回调。 */
85
+ export type SessionUiMessageStepStartCallback = () => Promise<void>;
86
+
87
+ /** 单个模型 UI stream 完成后的 canonical step 快照回调。 */
88
+ export type SessionUiMessageStepFinishCallback = (
89
+ message: SessionMessageRecordV1,
90
+ ) => Promise<void>;
91
+
92
+ /** 单个模型 UI stream 未完成时的 canonical step 清理回调。 */
93
+ export type SessionUiMessageStepAbortCallback = () => Promise<void>;
94
+
84
95
  /**
85
96
  * Session 执行结果。
86
97
  */
@@ -104,6 +115,14 @@ export interface SessionRunResult {
104
115
  */
105
116
  assistantMessage?: SessionMessageRecordV1 | null;
106
117
 
118
+ /**
119
+ * 工具运行期显式生成、并在 Assistant 末尾持久化的文件 Parts。
120
+ *
121
+ * 关键点(中文):该字段与聚合 `assistantMessage` 分离,Session 不需要从最终
122
+ * UIMessage 反推哪些文件来自工具通道。
123
+ */
124
+ assistant_file_parts?: FileUIPart[];
125
+
107
126
  /**
108
127
  * 本轮执行结束后待写入长期历史的 user 消息。
109
128
  *
@@ -610,6 +610,37 @@ export class SessionMessages {
610
610
  } as SessionMutation, message);
611
611
  }
612
612
 
613
+ /** @internal 原子提交当前 Assistant step 的 metadata 快照。 */
614
+ async commit_assistant_step(
615
+ message_id: string,
616
+ parts: SessionAssistantMessagePart[],
617
+ ): Promise<void> {
618
+ await this.enqueue_assistant_write(message_id, async () => {
619
+ const current = require_message([...this.messages_by_id.values()], message_id, "assistant");
620
+ require_streaming_assistant(current);
621
+ if (
622
+ parts.length !== current.parts.length ||
623
+ parts.some((part, index) =>
624
+ part.part_id !== current.parts[index]?.part_id ||
625
+ part.sequence !== current.parts[index]?.sequence
626
+ )
627
+ ) {
628
+ throw new Error(
629
+ `Assistant step changed canonical Part identity: ${message_id}`,
630
+ );
631
+ }
632
+ const created_at = Date.now();
633
+ const message: SessionAssistantMessage = {
634
+ ...current,
635
+ revision: current.revision + 1,
636
+ updated_at: created_at,
637
+ parts: structuredClone(parts),
638
+ };
639
+ await this.store.write_assistant_message(message);
640
+ this.accept_message(message);
641
+ });
642
+ }
643
+
613
644
  /** @internal 收口 Assistant Message。 */
614
645
  async complete_assistant_message(
615
646
  message_id: string,
@@ -471,6 +471,18 @@ export class SessionTurn {
471
471
  const writer = await ensure_assistant_writer();
472
472
  await writer.apply_chunk(chunk);
473
473
  },
474
+ on_ui_message_step_start: async () => {
475
+ const writer = await ensure_assistant_writer();
476
+ await writer.begin_step();
477
+ },
478
+ on_ui_message_step_finish: async (message) => {
479
+ const writer = await ensure_assistant_writer();
480
+ await writer.finish_step(from_ui_assistant_parts(message.parts));
481
+ },
482
+ on_ui_message_step_abort: async () => {
483
+ const writer = assistant_writer_ref.current;
484
+ if (writer) await writer.abort_step();
485
+ },
474
486
  on_tool_input_ready: async (tool_input) => {
475
487
  const writer = await ensure_assistant_writer();
476
488
  await writer.prepare_tool_input(tool_input);
@@ -511,12 +523,8 @@ export class SessionTurn {
511
523
  : [];
512
524
  const final_assistant_writer = assistant_writer_ref.current;
513
525
  if (final_assistant_writer) {
514
- for (const part of final_assistant_parts) {
515
- if (part.type === "file") {
516
- await final_assistant_writer.append_file_part(part);
517
- } else if (part.type === "tool") {
518
- await final_assistant_writer.reconcile_final_tool_part(part);
519
- }
526
+ for (const part of from_ui_assistant_parts(result.assistant_file_parts)) {
527
+ if (part.type === "file") await final_assistant_writer.append_file_part(part);
520
528
  }
521
529
  if (input.abort_signal?.aborted) {
522
530
  await final_assistant_writer.stop();
@@ -618,7 +626,11 @@ function is_assistant_content_chunk(type: string): boolean {
618
626
  type === "reasoning-delta" ||
619
627
  type === "reasoning-end" ||
620
628
  type.startsWith("tool-") ||
621
- type === "file"
629
+ type === "file" ||
630
+ type === "source-url" ||
631
+ type === "source-document" ||
632
+ type === "start-step" ||
633
+ type.startsWith("data-")
622
634
  );
623
635
  }
624
636