@downcity/agent 1.1.188 → 1.1.195

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 (78) hide show
  1. package/bin/executor/Executor.d.ts +4 -0
  2. package/bin/executor/Executor.d.ts.map +1 -1
  3. package/bin/executor/Executor.js +40 -1
  4. package/bin/executor/Executor.js.map +1 -1
  5. package/bin/executor/composer/compaction/SessionCompactionComposer.d.ts +9 -0
  6. package/bin/executor/composer/compaction/SessionCompactionComposer.d.ts.map +1 -1
  7. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.d.ts.map +1 -1
  8. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.js +1 -0
  9. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.js.map +1 -1
  10. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.d.ts +2 -0
  11. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.d.ts.map +1 -1
  12. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.js +49 -5
  13. package/bin/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.js.map +1 -1
  14. package/bin/executor/composer/history/jsonl/JsonlSessionHistoryComposer.d.ts +1 -1
  15. package/bin/executor/composer/history/jsonl/JsonlSessionHistoryComposer.d.ts.map +1 -1
  16. package/bin/executor/composer/history/jsonl/JsonlSessionHistoryComposer.js +2 -0
  17. package/bin/executor/composer/history/jsonl/JsonlSessionHistoryComposer.js.map +1 -1
  18. package/bin/executor/messages/SessionMessageCodec.d.ts +1 -1
  19. package/bin/executor/messages/SessionMessageCodec.d.ts.map +1 -1
  20. package/bin/executor/messages/SessionMessageCodec.js +6 -1
  21. package/bin/executor/messages/SessionMessageCodec.js.map +1 -1
  22. package/bin/executor/store/history/SessionHistoryStore.d.ts +22 -0
  23. package/bin/executor/store/history/SessionHistoryStore.d.ts.map +1 -1
  24. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.d.ts +7 -0
  25. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.d.ts.map +1 -1
  26. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js +53 -0
  27. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js.map +1 -1
  28. package/bin/executor/types/SessionMessages.d.ts +15 -2
  29. package/bin/executor/types/SessionMessages.d.ts.map +1 -1
  30. package/bin/executor/types/SessionMessages.js +9 -1
  31. package/bin/executor/types/SessionMessages.js.map +1 -1
  32. package/bin/index.d.ts +1 -0
  33. package/bin/index.d.ts.map +1 -1
  34. package/bin/index.js.map +1 -1
  35. package/bin/session/browse/Browse.d.ts.map +1 -1
  36. package/bin/session/browse/Browse.js +22 -0
  37. package/bin/session/browse/Browse.js.map +1 -1
  38. package/bin/session/services/SessionStateService.d.ts +21 -1
  39. package/bin/session/services/SessionStateService.d.ts.map +1 -1
  40. package/bin/session/services/SessionStateService.js +101 -11
  41. package/bin/session/services/SessionStateService.js.map +1 -1
  42. package/bin/session/services/SessionTurnService.d.ts.map +1 -1
  43. package/bin/session/services/SessionTurnService.js +9 -0
  44. package/bin/session/services/SessionTurnService.js.map +1 -1
  45. package/bin/session/services/SessionViewService.d.ts.map +1 -1
  46. package/bin/session/services/SessionViewService.js +42 -8
  47. package/bin/session/services/SessionViewService.js.map +1 -1
  48. package/bin/types/agent/SessionTypes.d.ts +22 -1
  49. package/bin/types/agent/SessionTypes.d.ts.map +1 -1
  50. package/bin/types/executor/SessionRunContext.d.ts +10 -0
  51. package/bin/types/executor/SessionRunContext.d.ts.map +1 -1
  52. package/bin/types/sdk/AgentSessionEvent.d.ts +2 -1
  53. package/bin/types/sdk/AgentSessionEvent.d.ts.map +1 -1
  54. package/bin/types/sdk/AgentSessionOperation.d.ts +99 -0
  55. package/bin/types/sdk/AgentSessionOperation.d.ts.map +1 -0
  56. package/bin/types/sdk/AgentSessionOperation.js +10 -0
  57. package/bin/types/sdk/AgentSessionOperation.js.map +1 -0
  58. package/package.json +3 -3
  59. package/scripts/session-operation-message.test.mjs +182 -0
  60. package/src/executor/Executor.ts +49 -1
  61. package/src/executor/composer/compaction/SessionCompactionComposer.ts +10 -0
  62. package/src/executor/composer/compaction/jsonl/JsonlSessionCompactionComposer.ts +1 -0
  63. package/src/executor/composer/compaction/jsonl/JsonlSessionCompactionExecutor.ts +64 -5
  64. package/src/executor/composer/history/jsonl/JsonlSessionHistoryComposer.ts +5 -1
  65. package/src/executor/messages/SessionMessageCodec.ts +11 -2
  66. package/src/executor/store/history/SessionHistoryStore.ts +27 -0
  67. package/src/executor/store/history/jsonl/JsonlSessionHistoryStore.ts +66 -0
  68. package/src/executor/types/SessionMessages.ts +26 -2
  69. package/src/index.ts +7 -0
  70. package/src/session/browse/Browse.ts +24 -0
  71. package/src/session/services/SessionStateService.ts +125 -11
  72. package/src/session/services/SessionTurnService.ts +8 -0
  73. package/src/session/services/SessionViewService.ts +47 -10
  74. package/src/types/agent/SessionTypes.ts +22 -1
  75. package/src/types/executor/SessionRunContext.ts +20 -0
  76. package/src/types/sdk/AgentSessionEvent.ts +2 -0
  77. package/src/types/sdk/AgentSessionOperation.ts +125 -0
  78. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,182 @@
1
+ /**
2
+ * @file 验证 operation message 的持久化、模型输入过滤与 timeline 投影。
3
+ *
4
+ * 关键点(中文)
5
+ * - operation message 会作为 UIMessage 写入 JSONL。
6
+ * - history composer 组装 LLM 输入时必须过滤 operation message。
7
+ * - timeline view 可以把 operation message 投影成前端可识别的 operation 事件。
8
+ */
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import fs from "node:fs/promises";
15
+
16
+ import { JsonlSessionHistoryStore } from "../bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js";
17
+ import { JsonlSessionHistoryComposer } from "../bin/executor/composer/history/jsonl/JsonlSessionHistoryComposer.js";
18
+ import { toSessionTimelineEvents } from "../bin/session/browse/Browse.js";
19
+ import { Agent } from "../bin/index.js";
20
+
21
+ test("operation message is persisted but filtered from LLM history", async () => {
22
+ const root_path = await fs.mkdtemp(
23
+ path.join(os.tmpdir(), "downcity-agent-operation-message-"),
24
+ );
25
+ const session_id = "operation_session";
26
+ const store = new JsonlSessionHistoryStore({
27
+ rootPath: root_path,
28
+ agentId: "operation_agent",
29
+ sessionId: session_id,
30
+ });
31
+ const composer = new JsonlSessionHistoryComposer({ store });
32
+
33
+ await store.append(
34
+ store.userText({
35
+ text: "hello",
36
+ metadata: { sessionId: session_id },
37
+ id: "u:1",
38
+ }),
39
+ );
40
+ await store.append(
41
+ store.operation({
42
+ operation: {
43
+ operationId: "op-1",
44
+ name: "compacting",
45
+ status: "started",
46
+ label: "Compacting session history",
47
+ turnId: "turn-1",
48
+ },
49
+ metadata: { sessionId: session_id },
50
+ id: "op:1",
51
+ }),
52
+ );
53
+ await store.append(
54
+ store.assistantText({
55
+ text: "world",
56
+ metadata: { sessionId: session_id },
57
+ id: "a:1",
58
+ }),
59
+ );
60
+
61
+ const persisted_messages = await store.list();
62
+ assert.equal(persisted_messages.length, 3);
63
+ assert.equal(persisted_messages[1].metadata.kind, "operation");
64
+ assert.equal(persisted_messages[1].metadata.operation.operationId, "op-1");
65
+
66
+ const model_messages = await composer.prepare({
67
+ query: "",
68
+ tools: {},
69
+ system: [],
70
+ model: {},
71
+ retryCount: 0,
72
+ });
73
+ assert.deepEqual(
74
+ model_messages.map((message) => message.id),
75
+ ["u:1", "a:1"],
76
+ );
77
+
78
+ const timeline_events = toSessionTimelineEvents(persisted_messages[1]);
79
+ assert.equal(timeline_events.length, 1);
80
+ assert.equal(timeline_events[0].role, "operation");
81
+ assert.equal(timeline_events[0].operationId, "op-1");
82
+ assert.equal(timeline_events[0].operationName, "compacting");
83
+ assert.equal(timeline_events[0].operationStatus, "started");
84
+ assert.equal(timeline_events[0].text, "Compacting session history");
85
+ });
86
+
87
+ test("session.set persists and publishes model-switching operations", async () => {
88
+ const agent_path = await fs.mkdtemp(
89
+ path.join(os.tmpdir(), "downcity-agent-model-switching-"),
90
+ );
91
+ const agent = new Agent({
92
+ id: "model_switching_agent",
93
+ path: agent_path,
94
+ });
95
+ try {
96
+ const session = await agent.session_collection().create_session({
97
+ sessionId: "model_switching_session",
98
+ });
99
+ const events = [];
100
+ const unsubscribe = session.subscribe((event) => {
101
+ if (event.type === "operation") events.push(event);
102
+ });
103
+
104
+ await session.set({
105
+ model: {
106
+ modelId: "test-model",
107
+ provider: "test",
108
+ },
109
+ });
110
+ unsubscribe();
111
+
112
+ assert.deepEqual(
113
+ events.map((event) => `${event.name}:${event.status}`),
114
+ ["model-switching:started", "model-switching:finished"],
115
+ );
116
+
117
+ const history = await session.history({ view: "timeline" });
118
+ const operation_events = history.items.filter(
119
+ (item) => item.role === "operation",
120
+ );
121
+ assert.deepEqual(
122
+ operation_events.map((event) => `${event.operationName}:${event.operationStatus}`),
123
+ ["model-switching:started", "model-switching:finished"],
124
+ );
125
+ } finally {
126
+ await agent.dispose();
127
+ }
128
+ });
129
+
130
+ test("session.fork persists and publishes history-forking operations on source session", async () => {
131
+ const agent_path = await fs.mkdtemp(
132
+ path.join(os.tmpdir(), "downcity-agent-history-forking-"),
133
+ );
134
+ const agent = new Agent({
135
+ id: "history_forking_agent",
136
+ path: agent_path,
137
+ });
138
+ try {
139
+ const session = await agent.session_collection().create_session({
140
+ sessionId: "history_forking_session",
141
+ });
142
+ await session.set({
143
+ model: {
144
+ modelId: "fork-test-model",
145
+ provider: "test",
146
+ },
147
+ });
148
+ await session.appendUserMessage({ text: "source message" });
149
+
150
+ const events = [];
151
+ const unsubscribe = session.subscribe((event) => {
152
+ if (event.type === "operation" && event.name === "history-forking") {
153
+ events.push(event);
154
+ }
155
+ });
156
+ const forked = await session.fork();
157
+ unsubscribe();
158
+
159
+ assert.deepEqual(
160
+ events.map((event) => `${event.name}:${event.status}`),
161
+ ["history-forking:started", "history-forking:finished"],
162
+ );
163
+ assert.notEqual(forked.id, session.id);
164
+
165
+ const source_history = await session.history({ view: "timeline" });
166
+ const source_fork_operations = source_history.items.filter(
167
+ (item) => item.operationName === "history-forking",
168
+ );
169
+ assert.deepEqual(
170
+ source_fork_operations.map((event) => event.operationStatus),
171
+ ["started", "finished"],
172
+ );
173
+
174
+ const forked_history = await forked.history({ view: "timeline" });
175
+ const forked_model_operations = forked_history.items.filter(
176
+ (item) => item.operationName === "model-switching",
177
+ );
178
+ assert.equal(forked_model_operations.length, 2);
179
+ } finally {
180
+ await agent.dispose();
181
+ }
182
+ });
@@ -22,11 +22,13 @@ import type { SessionContextComposer } from "@executor/composer/context/SessionC
22
22
  import type { SessionSystemComposer } from "@executor/composer/system/SessionSystemComposer.js";
23
23
  import { ExecutorInflightService } from "@executor/services/ExecutorInflightService.js";
24
24
  import { ExecutorRecoveryPolicy } from "@executor/services/ExecutorRecoveryPolicy.js";
25
+ import { generateId } from "@/utils/Id.js";
25
26
  import type { Logger } from "@/utils/logger/Logger.js";
26
27
  import type { JsonObject } from "@/types/common/Json.js";
27
28
  import type { SessionMessageV1 } from "@/executor/types/SessionMessages.js";
28
29
  import type { SessionExecutor } from "@/executor/types/SessionExecutor.js";
29
30
  import type { SessionRunContext } from "@/types/executor/SessionRunContext.js";
31
+ import type { AgentSessionOperationRecord } from "@/types/sdk/AgentSessionOperation.js";
30
32
  import type {
31
33
  SessionExecuteInput,
32
34
  SessionRunResult,
@@ -360,6 +362,7 @@ export class Executor implements SessionExecutor {
360
362
  const composed_context = await this.contextComposer.compose(run_context);
361
363
  const tools = this.bind_run_scope_to_tools(composed_context.tools, run_context);
362
364
  const system = await this.systemComposer.resolve(run_context);
365
+ let compaction_operation_id = "";
363
366
 
364
367
  try {
365
368
  if (retry_count > 0) {
@@ -368,13 +371,33 @@ export class Executor implements SessionExecutor {
368
371
  });
369
372
  }
370
373
 
374
+ const emit_compaction_operation = async (
375
+ operation: AgentSessionOperationRecord,
376
+ ): Promise<void> => {
377
+ if (operation.status === "started") {
378
+ compaction_operation_id = operation.operationId;
379
+ }
380
+ await this.emitOperation(run_context, operation);
381
+ };
382
+
371
383
  await this.compactionComposer.run({
372
384
  historyStore: this.historyStore,
373
385
  model,
374
386
  system,
375
387
  retryCount: retry_count,
388
+ onOperation: emit_compaction_operation,
389
+ });
390
+ } catch (error) {
391
+ await this.emitOperation(run_context, {
392
+ operationId:
393
+ compaction_operation_id ||
394
+ `compacting:${this.sessionId}:failed:${Date.now()}:${generateId()}`,
395
+ name: "compacting",
396
+ status: "failed",
397
+ label: "Session history compact failed",
398
+ error: error instanceof Error ? error.message : String(error),
399
+ visible: false,
376
400
  });
377
- } catch {
378
401
  // 压缩失败不阻断主流程,继续使用当前历史消息执行。
379
402
  }
380
403
 
@@ -469,6 +492,9 @@ export class Executor implements SessionExecutor {
469
492
  ...(typeof input?.onUiMessageChunkCallback === "function"
470
493
  ? { onUiMessageChunkCallback: input.onUiMessageChunkCallback }
471
494
  : {}),
495
+ ...(typeof input?.onOperationCallback === "function"
496
+ ? { onOperationCallback: input.onOperationCallback }
497
+ : {}),
472
498
  ...(input?.abortSignal ? { abortSignal: input.abortSignal } : {}),
473
499
  injectedUserMessages: Array.isArray(input?.injectedUserMessages)
474
500
  ? [...input.injectedUserMessages]
@@ -484,6 +510,28 @@ export class Executor implements SessionExecutor {
484
510
  };
485
511
  }
486
512
 
513
+ /**
514
+ * 发布一次 session operation。
515
+ */
516
+ private async emitOperation(
517
+ run_context: SessionRunContext,
518
+ operation: AgentSessionOperationRecord,
519
+ ): Promise<void> {
520
+ if (typeof run_context.onOperationCallback !== "function") return;
521
+ const operation_id =
522
+ String(operation.operationId || "").trim() ||
523
+ `operation:${this.sessionId}:${Date.now()}:${generateId()}`;
524
+ await run_context.onOperationCallback({
525
+ type: "operation",
526
+ sessionId: run_context.sessionId || this.sessionId,
527
+ ...operation,
528
+ operationId: operation_id,
529
+ ...(run_context.turnId && !operation.turnId
530
+ ? { turnId: run_context.turnId }
531
+ : {}),
532
+ });
533
+ }
534
+
487
535
  /**
488
536
  * 读取当前 session 模型。
489
537
  */
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { LanguageModel, SystemModelMessage } from "ai";
10
10
  import type { SessionHistoryStore } from "@/executor/store/history/SessionHistoryStore.js";
11
+ import type { AgentSessionOperationRecord } from "@/types/sdk/AgentSessionOperation.js";
11
12
 
12
13
  /**
13
14
  * compaction Composer 执行输入。
@@ -32,6 +33,15 @@ export type SessionCompactionInput = {
32
33
  * 当前重试次数(由 Executor 递增)。
33
34
  */
34
35
  retryCount: number;
36
+
37
+ /**
38
+ * 可选 operation 发布回调。
39
+ *
40
+ * 说明(中文)
41
+ * - 默认 compaction Composer 可在真正开始压缩时发布 `compacting`。
42
+ * - 回调由上层 Session 负责转成订阅事件与 operation message。
43
+ */
44
+ onOperation?: (operation: AgentSessionOperationRecord) => Promise<void>;
35
45
  };
36
46
 
37
47
  /**
@@ -79,6 +79,7 @@ export class JsonlSessionCompactionComposer implements SessionCompactionComposer
79
79
  maxInputTokensApprox: policy.maxInputTokensApprox,
80
80
  archiveOnCompact: policy.archiveOnCompact,
81
81
  compactRatio: policy.compactRatio,
82
+ ...(input.onOperation ? { onOperation: input.onOperation } : {}),
82
83
  });
83
84
  }
84
85
 
@@ -21,7 +21,9 @@ import type {
21
21
  SessionMessageV1,
22
22
  SessionMetadataV1,
23
23
  } from "@/executor/types/SessionMessages.js";
24
+ import { isSessionOperationMessage } from "@/executor/types/SessionMessages.js";
24
25
  import type { SessionHistoryMetaV1 } from "@/executor/types/SessionHistoryMeta.js";
26
+ import type { AgentSessionOperationRecord } from "@/types/sdk/AgentSessionOperation.js";
25
27
 
26
28
  export type SessionCompactParams = {
27
29
  model: LanguageModel;
@@ -30,6 +32,7 @@ export type SessionCompactParams = {
30
32
  maxInputTokensApprox: number;
31
33
  archiveOnCompact: boolean;
32
34
  compactRatio: number;
35
+ onOperation?: (operation: AgentSessionOperationRecord) => Promise<void>;
33
36
  };
34
37
 
35
38
  type SessionCompactDeps = {
@@ -60,6 +63,19 @@ export async function compactSessionMessagesIfNeeded(
60
63
  params: SessionCompactParams,
61
64
  ): Promise<{ compacted: boolean; reason?: string }> {
62
65
  const logger = getLogger(deps.rootPath, "info");
66
+ const operation_id = `compacting:${deps.sessionId}:${Date.now()}:${generateId()}`;
67
+ let operation_started = false;
68
+
69
+ const publish_operation = async (
70
+ operation: Omit<AgentSessionOperationRecord, "operationId" | "name">,
71
+ ): Promise<void> => {
72
+ if (typeof params.onOperation !== "function") return;
73
+ await params.onOperation({
74
+ operationId: operation_id,
75
+ name: "compacting",
76
+ ...operation,
77
+ });
78
+ };
63
79
 
64
80
  // 算法阶段(中文)
65
81
  // phase 1:snapshot(短锁)
@@ -68,7 +84,9 @@ export async function compactSessionMessagesIfNeeded(
68
84
  let snapshot: SessionMessageV1[] = [];
69
85
  let snapshotTailId = "";
70
86
  await deps.withWriteLock(async () => {
71
- snapshot = await deps.loadAll();
87
+ snapshot = (await deps.loadAll()).filter(
88
+ (message) => !isSessionOperationMessage(message),
89
+ );
72
90
  snapshotTailId =
73
91
  snapshot.length > 0
74
92
  ? String(snapshot[snapshot.length - 1].id || "")
@@ -100,6 +118,13 @@ export async function compactSessionMessagesIfNeeded(
100
118
  const older = snapshot.slice(0, compactCount);
101
119
  if (older.length === 0) return { compacted: false, reason: "nothing_to_compact" };
102
120
 
121
+ operation_started = true;
122
+ await publish_operation({
123
+ status: "started",
124
+ label: "Compacting session history",
125
+ progress: 0,
126
+ });
127
+
103
128
  const olderTextAll = extractPlainTextFromMessages(older);
104
129
  const maxOlderChars = 24_000;
105
130
  const olderText =
@@ -138,6 +163,12 @@ export async function compactSessionMessagesIfNeeded(
138
163
  },
139
164
  );
140
165
  summary = "(系统自动压缩:摘要生成失败,已丢弃更早历史,仅保留最近对话。)";
166
+ await publish_operation({
167
+ status: "progress",
168
+ label: "Compacting session history",
169
+ reason: "summary_fallback",
170
+ progress: 0.5,
171
+ });
141
172
  }
142
173
 
143
174
  const fromId = String(older[0]?.id || "");
@@ -159,10 +190,20 @@ export async function compactSessionMessagesIfNeeded(
159
190
  // snapshotTailId 用于 debug,不作为强一致性依赖。
160
191
  void snapshotTailId;
161
192
 
162
- const currentCompactCount = resolveCompactCount(current.length, compactRatio);
163
- const currentOlder = current.slice(0, currentCompactCount);
164
- const currentKept = current.slice(currentCompactCount);
193
+ const current_model_messages = current.filter(
194
+ (message) => !isSessionOperationMessage(message),
195
+ );
196
+ const currentCompactCount = resolveCompactCount(
197
+ current_model_messages.length,
198
+ compactRatio,
199
+ );
200
+ const currentOlder = current_model_messages.slice(0, currentCompactCount);
165
201
  if (currentOlder.length === 0) return;
202
+ const compacted_ids = new Set(
203
+ currentOlder
204
+ .map((message) => String(message.id || "").trim())
205
+ .filter(Boolean),
206
+ );
166
207
 
167
208
  if (params.archiveOnCompact) {
168
209
  const archivePath = path.join(
@@ -181,7 +222,13 @@ export async function compactSessionMessagesIfNeeded(
181
222
  );
182
223
  }
183
224
 
184
- const next = [summaryMsg, ...currentKept];
225
+ const next = [
226
+ summaryMsg,
227
+ ...current.filter((message) => {
228
+ const id = String(message.id || "").trim();
229
+ return !id || !compacted_ids.has(id);
230
+ }),
231
+ ];
185
232
 
186
233
  const messagesPath = deps.getMessagesFilePath();
187
234
  const tmp = messagesPath + ".tmp";
@@ -195,6 +242,18 @@ export async function compactSessionMessagesIfNeeded(
195
242
  });
196
243
  });
197
244
 
245
+ if (operation_started) {
246
+ await publish_operation({
247
+ status: "finished",
248
+ label: "Session history compacted",
249
+ progress: 1,
250
+ result: {
251
+ archiveId,
252
+ compactedCount: older.length,
253
+ },
254
+ });
255
+ }
256
+
198
257
  return { compacted: true };
199
258
  }
200
259
 
@@ -8,7 +8,10 @@
8
8
  */
9
9
 
10
10
  import type { Tool } from "ai";
11
- import type { SessionMessageV1 } from "@/executor/types/SessionMessages.js";
11
+ import {
12
+ isSessionOperationMessage,
13
+ type SessionMessageV1,
14
+ } from "@/executor/types/SessionMessages.js";
12
15
  import type { SessionHistoryStore } from "@/executor/store/history/SessionHistoryStore.js";
13
16
  import type {
14
17
  SessionHistoryComposer,
@@ -91,6 +94,7 @@ export class JsonlSessionHistoryComposer implements SessionHistoryComposer {
91
94
  parts,
92
95
  };
93
96
  })
97
+ .filter((message) => !isSessionOperationMessage(message))
94
98
  .filter((message) => Array.isArray(message.parts) && message.parts.length > 0);
95
99
  }
96
100
 
@@ -14,7 +14,10 @@ import {
14
14
  type Tool,
15
15
  type ToolSet,
16
16
  } from "ai";
17
- import type { SessionMessageV1 } from "@/executor/types/SessionMessages.js";
17
+ import {
18
+ isSessionOperationMessage,
19
+ type SessionMessageV1,
20
+ } from "@/executor/types/SessionMessages.js";
18
21
  import {
19
22
  hydrateFileUrlPartsForModel,
20
23
  injectFilePartsFromAttachments,
@@ -77,9 +80,15 @@ export async function toModelMessages(
77
80
  // 空输入快速返回,避免调用转换器的额外开销。
78
81
  if (!Array.isArray(messages) || messages.length === 0) return [];
79
82
 
83
+ // operation message 只服务前端时间线,不能传入模型。
84
+ const model_messages = messages.filter(
85
+ (message) => !isSessionOperationMessage(message),
86
+ );
87
+ if (model_messages.length === 0) return [];
88
+
80
89
  // 第一步(中文):在 user 消息上注入 file parts(多模态附件)。
81
90
  const enrichedMessages = await injectFilePartsFromAttachments(
82
- messages,
91
+ model_messages,
83
92
  projectRoot,
84
93
  );
85
94
 
@@ -13,6 +13,7 @@ import type {
13
13
  SessionMetadataV1,
14
14
  } from "@/executor/types/SessionMessages.js";
15
15
  import type { SessionSystemMessage } from "@/executor/types/SessionPrompts.js";
16
+ import type { AgentSessionOperationRecord } from "@/types/sdk/AgentSessionOperation.js";
16
17
 
17
18
  /**
18
19
  * compact 输入参数。
@@ -47,6 +48,11 @@ export type SessionHistoryCompactInput = {
47
48
  * 前段压缩比例(0-1)。
48
49
  */
49
50
  compactRatio: number;
51
+
52
+ /**
53
+ * 可选 operation 发布回调。
54
+ */
55
+ onOperation?: (operation: AgentSessionOperationRecord) => Promise<void>;
50
56
  };
51
57
 
52
58
  /**
@@ -157,4 +163,25 @@ export interface SessionHistoryStore {
157
163
  */
158
164
  source?: "egress" | "compact";
159
165
  }): SessionMessageV1;
166
+
167
+ /**
168
+ * 构造 operation 消息。
169
+ */
170
+ operation(input: {
171
+ /**
172
+ * 当前 operation 结构化记录。
173
+ */
174
+ operation: AgentSessionOperationRecord;
175
+
176
+ /**
177
+ * 消息元信息(除 schema 字段、kind、source 与 operation 字段)。
178
+ */
179
+ metadata: Omit<SessionMetadataV1, "v" | "ts" | "kind" | "source" | "operation"> &
180
+ Partial<Pick<SessionMetadataV1, "ts">>;
181
+
182
+ /**
183
+ * 可选消息 ID(默认自动生成)。
184
+ */
185
+ id?: string;
186
+ }): SessionMessageV1;
160
187
  }
@@ -21,6 +21,7 @@ import type {
21
21
  SessionMessageV1,
22
22
  SessionMetadataV1,
23
23
  } from "@/executor/types/SessionMessages.js";
24
+ import type { AgentSessionOperationRecord } from "@/types/sdk/AgentSessionOperation.js";
24
25
  import type { SessionSystemMessage } from "@/executor/types/SessionPrompts.js";
25
26
  import type { SessionHistoryMetaV1 } from "@/executor/types/SessionHistoryMeta.js";
26
27
  import type { SessionHistoryPathOverrides } from "@/executor/types/SessionHistoryPaths.js";
@@ -435,6 +436,44 @@ export class JsonlSessionHistoryStore implements SessionHistoryStore {
435
436
  return system.filter((item) => item && typeof item === "object");
436
437
  }
437
438
 
439
+ private normalizeOperation(
440
+ operation: AgentSessionOperationRecord,
441
+ ): AgentSessionOperationRecord {
442
+ const operation_id = String(operation.operationId || "").trim() || generateId();
443
+ const name = String(operation.name || "").trim() || "operation";
444
+ const status = String(operation.status || "").trim();
445
+ const normalized_status: AgentSessionOperationRecord["status"] =
446
+ status === "started" ||
447
+ status === "progress" ||
448
+ status === "finished" ||
449
+ status === "skipped" ||
450
+ status === "failed"
451
+ ? status
452
+ : "progress";
453
+ return {
454
+ operationId: operation_id,
455
+ name,
456
+ status: normalized_status,
457
+ ...(typeof operation.turnId === "string" && operation.turnId.trim()
458
+ ? { turnId: operation.turnId.trim() }
459
+ : {}),
460
+ ...(typeof operation.label === "string" && operation.label.trim()
461
+ ? { label: operation.label.trim() }
462
+ : {}),
463
+ ...(typeof operation.reason === "string" && operation.reason.trim()
464
+ ? { reason: operation.reason.trim() }
465
+ : {}),
466
+ ...(typeof operation.progress === "number" && Number.isFinite(operation.progress)
467
+ ? { progress: Math.max(0, Math.min(1, operation.progress)) }
468
+ : {}),
469
+ ...(operation.result !== undefined ? { result: operation.result } : {}),
470
+ ...(typeof operation.error === "string" && operation.error.trim()
471
+ ? { error: operation.error.trim() }
472
+ : {}),
473
+ ...(operation.visible === false ? { visible: false } : {}),
474
+ };
475
+ }
476
+
438
477
  async compact(input: SessionHistoryCompactInput): Promise<{
439
478
  compacted: boolean;
440
479
  reason?: string;
@@ -471,6 +510,7 @@ export class JsonlSessionHistoryStore implements SessionHistoryStore {
471
510
  maxInputTokensApprox: input.maxInputTokensApprox,
472
511
  archiveOnCompact: input.archiveOnCompact,
473
512
  compactRatio: input.compactRatio,
513
+ ...(input.onOperation ? { onOperation: input.onOperation } : {}),
474
514
  },
475
515
  );
476
516
  }
@@ -633,4 +673,30 @@ export class JsonlSessionHistoryStore implements SessionHistoryStore {
633
673
  parts: [{ type: "text", text: String(input.text ?? "") }],
634
674
  };
635
675
  }
676
+
677
+ operation(input: {
678
+ operation: AgentSessionOperationRecord;
679
+ metadata: Omit<SessionMetadataV1, "v" | "ts" | "kind" | "source" | "operation"> &
680
+ Partial<Pick<SessionMetadataV1, "ts">>;
681
+ id?: string;
682
+ }): SessionMessageV1 {
683
+ const operation = this.normalizeOperation(input.operation);
684
+ const label = String(
685
+ operation.label || `${operation.name}: ${operation.status}`,
686
+ ).trim();
687
+ const { ts, ...metadata } = input.metadata;
688
+ return {
689
+ id: input.id || `op:${this.sessionId}:${operation.operationId}:${generateId()}`,
690
+ role: "assistant",
691
+ metadata: {
692
+ v: 1,
693
+ ts: typeof ts === "number" ? ts : Date.now(),
694
+ ...metadata,
695
+ source: "operation",
696
+ kind: "operation",
697
+ operation,
698
+ },
699
+ parts: [{ type: "text", text: label }],
700
+ };
701
+ }
636
702
  }
@@ -9,6 +9,7 @@
9
9
 
10
10
  import type { UIMessage } from "ai";
11
11
  import type { JsonObject } from "@/types/common/Json.js";
12
+ import type { AgentSessionOperationRecord } from "@/types/sdk/AgentSessionOperation.js";
12
13
 
13
14
  /**
14
15
  * Session 消息:以 UIMessage[] 作为唯一事实源。
@@ -18,7 +19,7 @@ import type { JsonObject } from "@/types/common/Json.js";
18
19
  * - 默认只存 `role=user|assistant`
19
20
  * - compact 会把更早消息压缩为一条 `assistant` 摘要消息
20
21
  */
21
- export type SessionMessageKind = "normal" | "summary";
22
+ export type SessionMessageKind = "normal" | "summary" | "operation";
22
23
 
23
24
  /**
24
25
  * Session 消息来源类型。
@@ -28,7 +29,11 @@ export type SessionMessageKind = "normal" | "summary";
28
29
  * - `egress`:模型或 agent 输出写入的 assistant 消息。
29
30
  * - `compact`:由 compact 过程生成的摘要消息。
30
31
  */
31
- export type SessionMessageSource = "ingress" | "egress" | "compact";
32
+ export type SessionMessageSource =
33
+ | "ingress"
34
+ | "egress"
35
+ | "compact"
36
+ | "operation";
32
37
 
33
38
  /**
34
39
  * 入站消息细分类型。
@@ -68,6 +73,14 @@ export type SessionMetadataV1 = {
68
73
  archiveId?: string;
69
74
  /** compact 摘要所覆盖的原始消息范围。 */
70
75
  sourceRange?: SessionMessageSourceRangeV1;
76
+ /**
77
+ * operation message 对应的结构化操作记录。
78
+ *
79
+ * 说明(中文)
80
+ * - 仅当 `kind=operation` 或 `source=operation` 时存在。
81
+ * - 该字段供前端恢复时间线状态,不会进入 LLM 输入。
82
+ */
83
+ operation?: AgentSessionOperationRecord;
71
84
  /**
72
85
  * 扩展元信息。
73
86
  *
@@ -90,3 +103,14 @@ export type SessionUserMessageV1 = SessionMessageV1 & {
90
103
  /** 消息角色固定为 `user`。 */
91
104
  role: "user";
92
105
  };
106
+
107
+ /**
108
+ * 判断一条消息是否为 operation message。
109
+ */
110
+ export function isSessionOperationMessage(
111
+ message: SessionMessageV1 | null | undefined,
112
+ ): boolean {
113
+ if (!message || typeof message !== "object") return false;
114
+ const metadata = (message.metadata || null) as SessionMetadataV1 | null;
115
+ return metadata?.kind === "operation" || metadata?.source === "operation";
116
+ }