@downcity/agent 1.1.222 → 1.1.224

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 (166) hide show
  1. package/bin/agent/local/Agent.d.ts +1 -34
  2. package/bin/agent/local/Agent.d.ts.map +1 -1
  3. package/bin/agent/local/Agent.js +1 -44
  4. package/bin/agent/local/Agent.js.map +1 -1
  5. package/bin/agent/local/services/AgentAssemblyService.d.ts.map +1 -1
  6. package/bin/agent/local/services/AgentAssemblyService.js +35 -2
  7. package/bin/agent/local/services/AgentAssemblyService.js.map +1 -1
  8. package/bin/agent/local/services/AgentSessions.d.ts +6 -0
  9. package/bin/agent/local/services/AgentSessions.d.ts.map +1 -1
  10. package/bin/agent/local/services/AgentSessions.js +42 -0
  11. package/bin/agent/local/services/AgentSessions.js.map +1 -1
  12. package/bin/agent/remote/RemoteAgent.d.ts +1 -35
  13. package/bin/agent/remote/RemoteAgent.d.ts.map +1 -1
  14. package/bin/agent/remote/RemoteAgent.js +1 -37
  15. package/bin/agent/remote/RemoteAgent.js.map +1 -1
  16. package/bin/agent/remote/RemoteSession.d.ts +24 -45
  17. package/bin/agent/remote/RemoteSession.d.ts.map +1 -1
  18. package/bin/agent/remote/RemoteSession.js +74 -121
  19. package/bin/agent/remote/RemoteSession.js.map +1 -1
  20. package/bin/agent/remote/RemoteTransport.d.ts +11 -27
  21. package/bin/agent/remote/RemoteTransport.d.ts.map +1 -1
  22. package/bin/agent/remote/transports/HttpRemoteAgentTransport.d.ts +7 -21
  23. package/bin/agent/remote/transports/HttpRemoteAgentTransport.d.ts.map +1 -1
  24. package/bin/agent/remote/transports/HttpRemoteAgentTransport.js +11 -47
  25. package/bin/agent/remote/transports/HttpRemoteAgentTransport.js.map +1 -1
  26. package/bin/agent/remote/transports/RpcRemoteAgentTransport.d.ts +7 -20
  27. package/bin/agent/remote/transports/RpcRemoteAgentTransport.d.ts.map +1 -1
  28. package/bin/agent/remote/transports/RpcRemoteAgentTransport.js +8 -17
  29. package/bin/agent/remote/transports/RpcRemoteAgentTransport.js.map +1 -1
  30. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js +2 -2
  31. package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js.map +1 -1
  32. package/bin/executor/types/SessionHistoryPaths.d.ts +1 -1
  33. package/bin/index.d.ts +3 -3
  34. package/bin/index.d.ts.map +1 -1
  35. package/bin/index.js +1 -1
  36. package/bin/index.js.map +1 -1
  37. package/bin/rpc/Client.d.ts +7 -36
  38. package/bin/rpc/Client.d.ts.map +1 -1
  39. package/bin/rpc/Client.js +28 -78
  40. package/bin/rpc/Client.js.map +1 -1
  41. package/bin/session/Session.d.ts +16 -8
  42. package/bin/session/Session.d.ts.map +1 -1
  43. package/bin/session/Session.js +66 -21
  44. package/bin/session/Session.js.map +1 -1
  45. package/bin/session/browse/Browse.d.ts.map +1 -1
  46. package/bin/session/browse/Browse.js +49 -18
  47. package/bin/session/browse/Browse.js.map +1 -1
  48. package/bin/session/recorder/JsonlSessionMessageStore.d.ts +30 -24
  49. package/bin/session/recorder/JsonlSessionMessageStore.d.ts.map +1 -1
  50. package/bin/session/recorder/JsonlSessionMessageStore.js +163 -59
  51. package/bin/session/recorder/JsonlSessionMessageStore.js.map +1 -1
  52. package/bin/session/recorder/SessionMessageCodec.d.ts.map +1 -1
  53. package/bin/session/recorder/SessionMessageCodec.js +6 -1
  54. package/bin/session/recorder/SessionMessageCodec.js.map +1 -1
  55. package/bin/session/recorder/SessionRecorder.d.ts +20 -11
  56. package/bin/session/recorder/SessionRecorder.d.ts.map +1 -1
  57. package/bin/session/recorder/SessionRecorder.js +148 -103
  58. package/bin/session/recorder/SessionRecorder.js.map +1 -1
  59. package/bin/session/recorder/SessionRecorderCompaction.d.ts.map +1 -1
  60. package/bin/session/recorder/SessionRecorderCompaction.js +1 -0
  61. package/bin/session/recorder/SessionRecorderCompaction.js.map +1 -1
  62. package/bin/session/recorder/SessionRecorderHistoryStore.d.ts +5 -5
  63. package/bin/session/recorder/SessionRecorderHistoryStore.d.ts.map +1 -1
  64. package/bin/session/recorder/SessionRecorderHistoryStore.js +5 -5
  65. package/bin/session/recorder/SessionRecorderHistoryStore.js.map +1 -1
  66. package/bin/session/runtime/SessionEventHub.d.ts +12 -5
  67. package/bin/session/runtime/SessionEventHub.d.ts.map +1 -1
  68. package/bin/session/runtime/SessionEventHub.js +11 -6
  69. package/bin/session/runtime/SessionEventHub.js.map +1 -1
  70. package/bin/session/runtime/SessionPromptRuntime.d.ts +2 -2
  71. package/bin/session/runtime/SessionPromptRuntime.d.ts.map +1 -1
  72. package/bin/session/runtime/SessionPromptRuntime.js +35 -17
  73. package/bin/session/runtime/SessionPromptRuntime.js.map +1 -1
  74. package/bin/session/services/SessionStateService.d.ts +2 -2
  75. package/bin/session/services/SessionStateService.d.ts.map +1 -1
  76. package/bin/session/services/SessionStateService.js +6 -2
  77. package/bin/session/services/SessionStateService.js.map +1 -1
  78. package/bin/session/services/SessionTurnService.d.ts +2 -7
  79. package/bin/session/services/SessionTurnService.d.ts.map +1 -1
  80. package/bin/session/services/SessionTurnService.js +2 -8
  81. package/bin/session/services/SessionTurnService.js.map +1 -1
  82. package/bin/session/storage/Paths.d.ts +1 -1
  83. package/bin/session/storage/Paths.d.ts.map +1 -1
  84. package/bin/session/storage/Paths.js +2 -2
  85. package/bin/session/storage/Paths.js.map +1 -1
  86. package/bin/session/storage/RuntimeSessionPort.d.ts +3 -4
  87. package/bin/session/storage/RuntimeSessionPort.d.ts.map +1 -1
  88. package/bin/session/storage/RuntimeSessionPort.js +2 -2
  89. package/bin/session/storage/RuntimeSessionPort.js.map +1 -1
  90. package/bin/types/agent/SessionActor.d.ts +11 -7
  91. package/bin/types/agent/SessionActor.d.ts.map +1 -1
  92. package/bin/types/rpc/RpcProtocol.d.ts +35 -62
  93. package/bin/types/rpc/RpcProtocol.d.ts.map +1 -1
  94. package/bin/types/runtime/agent/AgentContext.d.ts +4 -4
  95. package/bin/types/runtime/agent/AgentContext.d.ts.map +1 -1
  96. package/bin/types/runtime/agent/AgentContext.js.map +1 -1
  97. package/bin/types/session/SessionApproval.d.ts +97 -0
  98. package/bin/types/session/SessionApproval.d.ts.map +1 -0
  99. package/bin/types/session/SessionApproval.js +7 -0
  100. package/bin/types/session/SessionApproval.js.map +1 -0
  101. package/bin/types/session/SessionMessage.d.ts +10 -4
  102. package/bin/types/session/SessionMessage.d.ts.map +1 -1
  103. package/bin/types/session/SessionMutation.d.ts +116 -0
  104. package/bin/types/session/SessionMutation.d.ts.map +1 -0
  105. package/bin/types/session/SessionMutation.js +19 -0
  106. package/bin/types/session/SessionMutation.js.map +1 -0
  107. package/bin/types/session/SessionOptions.d.ts +9 -0
  108. package/bin/types/session/SessionOptions.d.ts.map +1 -1
  109. package/package.json +3 -3
  110. package/scripts/remote-session-reconnect.test.mjs +7 -3
  111. package/scripts/session-history-inflight.test.mjs +2 -2
  112. package/scripts/session-prompt-runtime.test.mjs +3 -3
  113. package/scripts/session-recorder.test.mjs +100 -146
  114. package/src/agent/local/Agent.ts +1 -55
  115. package/src/agent/local/services/AgentAssemblyService.ts +39 -3
  116. package/src/agent/local/services/AgentSessions.ts +47 -0
  117. package/src/agent/remote/RemoteAgent.ts +1 -54
  118. package/src/agent/remote/RemoteSession.ts +105 -163
  119. package/src/agent/remote/RemoteTransport.ts +23 -34
  120. package/src/agent/remote/transports/HttpRemoteAgentTransport.ts +33 -90
  121. package/src/agent/remote/transports/RpcRemoteAgentTransport.ts +22 -39
  122. package/src/executor/store/history/jsonl/JsonlSessionHistoryStore.ts +2 -2
  123. package/src/executor/types/SessionHistoryPaths.ts +1 -1
  124. package/src/index.ts +20 -12
  125. package/src/rpc/Client.ts +48 -106
  126. package/src/session/Session.ts +91 -34
  127. package/src/session/browse/Browse.ts +51 -22
  128. package/src/session/recorder/JsonlSessionMessageStore.ts +170 -86
  129. package/src/session/recorder/SessionMessageCodec.ts +6 -1
  130. package/src/session/recorder/SessionRecorder.ts +183 -124
  131. package/src/session/recorder/SessionRecorderCompaction.ts +1 -0
  132. package/src/session/recorder/SessionRecorderHistoryStore.ts +5 -5
  133. package/src/session/runtime/SessionEventHub.ts +25 -12
  134. package/src/session/runtime/SessionPromptRuntime.ts +37 -19
  135. package/src/session/services/SessionStateService.ts +8 -6
  136. package/src/session/services/SessionTurnService.ts +3 -14
  137. package/src/session/storage/Paths.ts +2 -2
  138. package/src/session/storage/RuntimeSessionPort.ts +8 -11
  139. package/src/types/agent/SessionActor.ts +23 -20
  140. package/src/types/rpc/RpcProtocol.ts +34 -71
  141. package/src/types/runtime/agent/AgentContext.ts +7 -11
  142. package/src/types/session/SessionApproval.ts +108 -0
  143. package/src/types/session/SessionMessage.ts +10 -4
  144. package/src/types/session/SessionMutation.ts +141 -0
  145. package/src/types/session/SessionOptions.ts +25 -0
  146. package/tsconfig.tsbuildinfo +1 -1
  147. package/bin/session/recorder/SessionMessageReducer.d.ts +0 -12
  148. package/bin/session/recorder/SessionMessageReducer.d.ts.map +0 -1
  149. package/bin/session/recorder/SessionMessageReducer.js +0 -109
  150. package/bin/session/recorder/SessionMessageReducer.js.map +0 -1
  151. package/bin/session/runtime/SessionMessageEventHub.d.ts +0 -15
  152. package/bin/session/runtime/SessionMessageEventHub.d.ts.map +0 -1
  153. package/bin/session/runtime/SessionMessageEventHub.js +0 -28
  154. package/bin/session/runtime/SessionMessageEventHub.js.map +0 -1
  155. package/bin/types/sdk/AgentSessionEvent.d.ts +0 -303
  156. package/bin/types/sdk/AgentSessionEvent.d.ts.map +0 -1
  157. package/bin/types/sdk/AgentSessionEvent.js +0 -9
  158. package/bin/types/sdk/AgentSessionEvent.js.map +0 -1
  159. package/bin/types/session/SessionMessageMutation.d.ts +0 -92
  160. package/bin/types/session/SessionMessageMutation.d.ts.map +0 -1
  161. package/bin/types/session/SessionMessageMutation.js +0 -21
  162. package/bin/types/session/SessionMessageMutation.js.map +0 -1
  163. package/src/session/recorder/SessionMessageReducer.ts +0 -155
  164. package/src/session/runtime/SessionMessageEventHub.ts +0 -35
  165. package/src/types/sdk/AgentSessionEvent.ts +0 -372
  166. package/src/types/session/SessionMessageMutation.ts +0 -121
@@ -23,6 +23,7 @@ import type {
23
23
  } from "@/types/agent/SessionTypes.js";
24
24
  import type { AgentSession } from "@/types/agent/SessionActor.js";
25
25
  import {
26
+ getSdkAgentSessionAssistantMessagePath,
26
27
  getSdkAgentSessionDirPath,
27
28
  } from "@/session/storage/Paths.js";
28
29
  import { resolveSystemTimezone } from "@/session/storage/Metadata.js";
@@ -30,15 +31,18 @@ import { createRuntimeSessionPort } from "@/session/storage/RuntimeSessionPort.j
30
31
  import { SessionSystemBuilder } from "@/session/SessionSystemBuilder.js";
31
32
  import type { SessionPort } from "@/types/runtime/agent/AgentContext.js";
32
33
  import type {
33
- AgentSessionSubscriber,
34
- AgentSessionUnsubscribe,
35
- } from "@/types/sdk/AgentSessionEvent.js";
34
+ SessionMutation,
35
+ SessionMutationSubscriber,
36
+ SessionMutationUnsubscribe,
37
+ } from "@/types/session/SessionMutation.js";
36
38
  import type {
37
- ListSessionMessageChangesInput,
38
- SessionMessageMutationPage,
39
- SessionMessageMutationSubscriber,
40
- SessionMessageMutationUnsubscribe,
41
- } from "@/types/session/SessionMessageMutation.js";
39
+ ResolveSessionApprovalInput,
40
+ SessionApproval,
41
+ SessionApprovalModeSnapshot,
42
+ SessionApprovalResult,
43
+ SetSessionApprovalModeInput,
44
+ SessionApprovalRuntimeEvent,
45
+ } from "@/types/session/SessionApproval.js";
42
46
  import type {
43
47
  ListSessionMessagesInput,
44
48
  SessionMessagePage,
@@ -47,7 +51,6 @@ import type { AgentSessionPromptInput } from "@/types/sdk/AgentSessionPrompt.js"
47
51
  import type { AgentSessionStopResult } from "@/types/sdk/AgentSessionStop.js";
48
52
  import type { AgentSessionTurnHandle } from "@/types/sdk/AgentSessionTurn.js";
49
53
  import { SessionEventHub } from "@/session/runtime/SessionEventHub.js";
50
- import { SessionMessageEventHub } from "@/session/runtime/SessionMessageEventHub.js";
51
54
  import { SessionStateService } from "@/session/services/SessionStateService.js";
52
55
  import { SessionTurnService } from "@/session/services/SessionTurnService.js";
53
56
  import { SessionViewService } from "@/session/services/SessionViewService.js";
@@ -83,8 +86,11 @@ export class Session implements AgentSession {
83
86
  private readonly recorder: SessionRecorder;
84
87
  private readonly historyComposer: SessionHistoryComposer;
85
88
  private readonly executor: Executor;
86
- private readonly eventHub = new SessionEventHub();
87
- private readonly messageEventHub = new SessionMessageEventHub();
89
+ private readonly eventHub: SessionEventHub;
90
+ private readonly list_approvals: SessionOptions["list_approvals"];
91
+ private readonly get_approval_mode: SessionOptions["get_approval_mode"];
92
+ private readonly set_approval_mode_hook: SessionOptions["set_approval_mode"];
93
+ private readonly resolve_approval_hook: SessionOptions["resolve_approval"];
88
94
  private readonly localState: SessionLocalState;
89
95
  private readonly stateService: SessionStateService;
90
96
  private readonly turnService: SessionTurnService;
@@ -102,6 +108,10 @@ export class Session implements AgentSession {
102
108
  this.getPluginSystemBlocks = options.getPluginSystemBlocks;
103
109
  this.ensureConfiguredHook = options.ensureConfigured;
104
110
  this.composers = options.composers;
111
+ this.list_approvals = options.list_approvals;
112
+ this.get_approval_mode = options.get_approval_mode;
113
+ this.set_approval_mode_hook = options.set_approval_mode;
114
+ this.resolve_approval_hook = options.resolve_approval;
105
115
  if (!this.id) {
106
116
  throw new Error("Session requires a non-empty sessionId");
107
117
  }
@@ -112,12 +122,20 @@ export class Session implements AgentSession {
112
122
  throw new Error("Session requires a non-empty projectRoot");
113
123
  }
114
124
 
125
+ this.eventHub = new SessionEventHub({
126
+ create_reply: (mutation) => ({
127
+ approval: async ({ decision }) => {
128
+ const approval_id = require_approval_id(mutation);
129
+ return await this.resolve_approval({ approval_id, decision });
130
+ },
131
+ }),
132
+ });
115
133
  this.messageStore = this.create_message_store();
116
134
  this.recorder = new SessionRecorder({
117
135
  session_id: this.id,
118
136
  store: this.messageStore,
119
137
  publish: (mutation) => {
120
- this.messageEventHub.publish(mutation);
138
+ this.eventHub.publish(mutation);
121
139
  },
122
140
  });
123
141
  this.historyStore = this.create_history_store();
@@ -229,19 +247,44 @@ export class Session implements AgentSession {
229
247
  * 订阅当前 Session 的未来事件。
230
248
  */
231
249
  subscribe(
232
- subscriber: SessionMessageMutationSubscriber,
233
- ): SessionMessageMutationUnsubscribe {
234
- return this.messageEventHub.subscribe(subscriber);
235
- }
236
-
237
- /** 订阅远程 transport 所需的 Message lifecycle 事件。 */
238
- subscribe_transport(subscriber: AgentSessionSubscriber): AgentSessionUnsubscribe {
239
- const unsubscribe_message = this.messageEventHub.subscribe(subscriber);
240
- const unsubscribe_lifecycle = this.turnService.subscribe(subscriber);
241
- return () => {
242
- unsubscribe_message();
243
- unsubscribe_lifecycle();
244
- };
250
+ subscriber: SessionMutationSubscriber,
251
+ ): SessionMutationUnsubscribe {
252
+ return this.eventHub.subscribe(subscriber);
253
+ }
254
+
255
+ /** 列出当前 Session pending 工具审批。 */
256
+ async approvals(): Promise<SessionApproval[]> {
257
+ return await this.list_approvals(this.id);
258
+ }
259
+
260
+ /** 读取当前 Session 的工具审批模式。 */
261
+ async approval_mode(): Promise<SessionApprovalModeSnapshot> {
262
+ return await this.get_approval_mode(this.id);
263
+ }
264
+
265
+ /** 更新当前 Session 的工具审批模式。 */
266
+ async set_approval_mode(input: SetSessionApprovalModeInput): Promise<SessionApprovalModeSnapshot> {
267
+ return await this.set_approval_mode_hook(this.id, input);
268
+ }
269
+
270
+ /** 处理当前 Session 的 pending 工具审批。 */
271
+ async resolve_approval(input: ResolveSessionApprovalInput): Promise<SessionApprovalResult> {
272
+ return await this.resolve_approval_hook(this.id, input);
273
+ }
274
+
275
+ private async apply_runtime_event(event: SessionApprovalRuntimeEvent): Promise<void> {
276
+ if (event.type === "tool-approval-request") {
277
+ await this.recorder.require_tool_approval({
278
+ tool_call_id: event.tool_call_id,
279
+ approval_id: event.approval_id,
280
+ });
281
+ return;
282
+ }
283
+ await this.recorder.resolve_tool_approval({
284
+ tool_call_id: event.tool_call_id,
285
+ approval_id: event.approval_id,
286
+ decision: event.decision,
287
+ });
245
288
  }
246
289
 
247
290
  /**
@@ -280,13 +323,6 @@ export class Session implements AgentSession {
280
323
  return await this.recorder.list_messages(input);
281
324
  }
282
325
 
283
- /** 读取当前 Session 的增量 Message Mutation。 */
284
- async message_changes(
285
- input: ListSessionMessageChangesInput,
286
- ): Promise<SessionMessageMutationPage> {
287
- return await this.recorder.list_message_changes(input);
288
- }
289
-
290
326
  /**
291
327
  * 读取当前 session 生效的 system 快照。
292
328
  */
@@ -319,8 +355,8 @@ export class Session implements AgentSession {
319
355
  prompt: async (input) => await this.prompt(input),
320
356
  stop: async () => await this.stop(),
321
357
  subscribe: (subscriber) => this.subscribe(subscriber),
322
- publishEvent: (event) => {
323
- this.eventHub.publish(event);
358
+ publishEvent: async (event) => {
359
+ await this.apply_runtime_event(event);
324
360
  },
325
361
  append_user_message: async (message_params) => {
326
362
  await this.stateService.append_user_message(message_params);
@@ -356,6 +392,10 @@ export class Session implements AgentSession {
356
392
  getPluginSystemBlocks: this.getPluginSystemBlocks,
357
393
  ensureConfigured: this.ensureConfiguredHook,
358
394
  composers: this.composers,
395
+ list_approvals: this.list_approvals,
396
+ get_approval_mode: this.get_approval_mode,
397
+ set_approval_mode: this.set_approval_mode_hook,
398
+ resolve_approval: this.resolve_approval_hook,
359
399
  });
360
400
  }
361
401
 
@@ -383,6 +423,11 @@ export class Session implements AgentSession {
383
423
  return new JsonlSessionMessageStore({
384
424
  session_id: this.id,
385
425
  file_path: `${messages_dir_path}/messages.jsonl`,
426
+ assistant_message_file_path: getSdkAgentSessionAssistantMessagePath(
427
+ this.projectRoot,
428
+ this.agentId,
429
+ this.id,
430
+ ),
386
431
  });
387
432
  }
388
433
 
@@ -505,3 +550,15 @@ export class Session implements AgentSession {
505
550
  return input;
506
551
  }
507
552
  }
553
+
554
+ function require_approval_id(mutation: SessionMutation): string {
555
+ if (
556
+ mutation.variant === "part" &&
557
+ mutation.type === "tool" &&
558
+ mutation.part.state === "approval-required" &&
559
+ mutation.part.approval_id
560
+ ) {
561
+ return mutation.part.approval_id;
562
+ }
563
+ throw new Error("Current Session Mutation is not an approval-required Tool Part");
564
+ }
@@ -45,6 +45,8 @@ import { getSdkAgentArchivedSessionsDirPath } from "@/session/storage/Paths.js";
45
45
  import { getSdkAgentArchivedSessionMessagesPath } from "@/session/storage/Paths.js";
46
46
  import { getSdkAgentArchivedSessionMetaPath } from "@/session/storage/Paths.js";
47
47
  import { readSessionMetadataFromPath } from "@/session/storage/Metadata.js";
48
+ import { to_executor_ui_message } from "@/session/recorder/SessionMessageCodec.js";
49
+ import type { SessionMessage } from "@/types/session/SessionMessage.js";
48
50
 
49
51
  type AnyUiPart = UIMessagePart<Record<string, never>, Record<string, never>>;
50
52
 
@@ -324,46 +326,73 @@ export function toSessionTimelineEvents(
324
326
  export async function loadSessionMessagesFromPath(
325
327
  filePath: string,
326
328
  ): Promise<SessionRecordV1[]> {
327
- const messages: SessionRecordV1[] = [];
329
+ const messages_by_id = new Map<string, SessionMessage>();
328
330
  if (await fs.pathExists(filePath)) {
329
331
  const raw = await fs.readFile(filePath, "utf-8");
330
332
  const lines = raw.split("\n").filter(Boolean);
331
333
  for (const line of lines) {
332
334
  try {
333
- const parsed = JSON.parse(line) as SessionRecordV1;
334
- if (!parsed || typeof parsed !== "object") continue;
335
- const candidate = parsed as { type?: unknown; role?: unknown };
336
- if (
337
- candidate.type !== "action" &&
338
- candidate.role !== "user" &&
339
- candidate.role !== "assistant"
340
- ) {
341
- continue;
335
+ const message = JSON.parse(line) as SessionMessage;
336
+ if (!is_canonical_session_message(message)) continue;
337
+ const previous = messages_by_id.get(message.message_id);
338
+ if (!previous || message.revision > previous.revision) {
339
+ messages_by_id.set(message.message_id, message);
342
340
  }
343
- messages.push(parsed);
344
341
  } catch {
345
342
  // 关键点(中文):单行损坏不影响整个 session 的可读性。
346
343
  }
347
344
  }
348
345
  }
349
346
 
350
- const inflight_path = filePath.replace(/messages\.jsonl$/, "inflight.json");
347
+ const inflight_path = filePath.replace(/messages\.jsonl$/, "assistant_message.json");
351
348
  if (await fs.pathExists(inflight_path)) {
352
349
  try {
353
- const parsed = (await fs.readJson(inflight_path)) as SessionRecordV1;
354
- if (
355
- is_session_message_record(parsed) &&
356
- parsed.role === "assistant" &&
357
- Array.isArray(parsed.parts)
358
- ) {
359
- messages.push(parsed);
350
+ const message = (await fs.readJson(inflight_path)) as SessionMessage;
351
+ if (is_canonical_session_message(message) && message.type === "assistant") {
352
+ messages_by_id.set(message.message_id, message);
360
353
  }
361
354
  } catch {
362
- // ignore invalid inflight snapshot
355
+ // 运行中快照损坏时仍返回已经完成的历史。
363
356
  }
364
357
  }
365
358
 
366
- return messages;
359
+ return [...messages_by_id.values()]
360
+ .sort((left, right) => left.sequence - right.sequence)
361
+ .flatMap(project_canonical_message_record);
362
+ }
363
+
364
+ function is_canonical_session_message(input: unknown): input is SessionMessage {
365
+ if (!input || typeof input !== "object") return false;
366
+ const candidate = input as Partial<SessionMessage>;
367
+ return (
368
+ typeof candidate.message_id === "string" &&
369
+ typeof candidate.session_id === "string" &&
370
+ typeof candidate.sequence === "number" &&
371
+ typeof candidate.revision === "number" &&
372
+ (candidate.type === "user" ||
373
+ candidate.type === "assistant" ||
374
+ candidate.type === "action" ||
375
+ candidate.type === "error")
376
+ );
377
+ }
378
+
379
+ function project_canonical_message_record(message: SessionMessage): SessionRecordV1[] {
380
+ const projected = to_executor_ui_message(message);
381
+ if (projected) return [projected];
382
+ if (message.type !== "action") return [];
383
+ return [{
384
+ type: "action",
385
+ id: message.message_id,
386
+ title: message.title,
387
+ ...(message.description ? { description: message.description } : {}),
388
+ state: message.status,
389
+ metadata: {
390
+ v: 1,
391
+ ts: message.updated_at,
392
+ sessionId: message.session_id,
393
+ ...(message.turn_id ? { turnId: message.turn_id } : {}),
394
+ },
395
+ }];
367
396
  }
368
397
 
369
398
  /**
@@ -481,7 +510,7 @@ async function resolve_session_summary_metadata(input: {
481
510
  const history_bytes = await fs.stat(input.messagesPath)
482
511
  .then((file_stat) => file_stat.size)
483
512
  .catch(() => 0);
484
- const inflight_path = `${input.messagesPath.slice(0, -"messages.jsonl".length)}inflight.json`;
513
+ const inflight_path = `${input.messagesPath.slice(0, -"messages.jsonl".length)}assistant_message.json`;
485
514
  const has_inflight = await fs.pathExists(inflight_path);
486
515
  if (
487
516
  !input.refresh &&
@@ -1,121 +1,217 @@
1
1
  /**
2
- * 基于单一 messages.jsonl SessionMessage mutation store。
2
+ * SessionMessageJSONL 快照存储。
3
3
  *
4
- * 文件中每行是一条完整 mutation。消息快照通过 reducer 重放得到,不维护第二份文件。
4
+ * messages.jsonl 只保存完整 Message 快照;运行中的唯一 Assistant 使用
5
+ * assistant_message.json 原子覆盖,避免把每个 delta 写入历史文件。
5
6
  */
6
7
 
7
8
  import fs from "fs-extra";
8
9
  import { open, readFile, stat } from "node:fs/promises";
9
10
  import path from "node:path";
10
11
  import { generateId } from "@/utils/Id.js";
11
- import { reduce_session_messages } from "@/session/recorder/SessionMessageReducer.js";
12
- import type { SessionMessage } from "@/types/session/SessionMessage.js";
13
- import type { SessionMessageMutation } from "@/types/session/SessionMessageMutation.js";
12
+ import type { SessionAssistantMessage, SessionMessage } from "@/types/session/SessionMessage.js";
14
13
 
15
- /** JSONL message store 构造参数。 */
14
+ /** JSONL 快照存储构造参数。 */
16
15
  export interface JsonlSessionMessageStoreOptions {
17
- /** 当前 session 标识。 */
16
+ /** 当前 Session 标识。 */
18
17
  session_id: string;
19
- /** canonical messages.jsonl 的绝对路径。 */
18
+ /** messages.jsonl 的绝对或相对路径。 */
20
19
  file_path: string;
20
+ /** Assistant 运行中快照路径,默认与 messages.jsonl 同目录。 */
21
+ assistant_message_file_path?: string;
21
22
  }
22
23
 
23
- /** Recorder 在锁内构造下一条 mutation 时可用的序列状态。 */
24
+ /** 在写锁内计算新 Message 所需的状态。 */
24
25
  export interface SessionMessageCommitState {
25
- /** 下一条 mutation 应使用的提交顺序。 */
26
- commit_sequence: number;
27
- /** 下一条新消息应使用的线性顺序。 */
26
+ /** 下一条新 Message 的线性顺序。 */
28
27
  message_sequence: number;
29
- /** 当前已重放的线性消息列表。 */
28
+ /** 当前已持久化的完整 Message 快照。 */
30
29
  messages: SessionMessage[];
31
30
  }
32
31
 
33
- /** 单一 JSONL mutation store。 */
32
+ /** 单一 JSONL 快照存储。 */
34
33
  export class JsonlSessionMessageStore {
35
34
  readonly session_id: string;
36
- private readonly messages_file_path: string;
35
+ readonly messages_file_path: string;
36
+ readonly assistant_message_file_path: string;
37
37
  private readonly lock_file_path: string;
38
38
 
39
39
  constructor(options: JsonlSessionMessageStoreOptions) {
40
40
  this.session_id = String(options.session_id || "").trim();
41
41
  this.messages_file_path = path.resolve(options.file_path);
42
+ this.assistant_message_file_path = path.resolve(
43
+ options.assistant_message_file_path ||
44
+ path.join(path.dirname(this.messages_file_path), "assistant_message.json"),
45
+ );
42
46
  this.lock_file_path = `${this.messages_file_path}.lock`;
43
- if (!this.session_id) {
44
- throw new Error("JsonlSessionMessageStore requires session_id");
45
- }
47
+ if (!this.session_id) throw new Error("JsonlSessionMessageStore requires session_id");
46
48
  }
47
49
 
48
- /** 创建空文件并校验已有 Mutation 可重放。 */
50
+ /** 创建目录和文件,并验证已有快照。 */
49
51
  async initialize(): Promise<void> {
50
52
  await this.ensure_layout();
51
- reduce_session_messages(await this.read_mutations_unsafe());
53
+ const messages = await this.read_messages_unsafe();
54
+ const draft = await this.read_assistant_message();
55
+ if (draft && draft.session_id !== this.session_id) {
56
+ throw new Error("assistant_message.json session_id mismatch");
57
+ }
58
+ const finalized = draft && messages.find((message) => message.message_id === draft.message_id);
59
+ if (draft && finalized && finalized.revision >= draft.revision && finalized.type === "assistant" && finalized.status !== "streaming") {
60
+ await fs.remove(this.assistant_message_file_path);
61
+ }
52
62
  }
53
63
 
54
- /** 读取全部合法 mutation;损坏行会带行号抛错,避免静默产生错误历史。 */
55
- async list_mutations(): Promise<SessionMessageMutation[]> {
64
+ /** 读取 JSONL 中每一行完整快照,并按 Message revision 折叠。 */
65
+ async list_messages(): Promise<SessionMessage[]> {
56
66
  await this.ensure_layout();
57
- return await this.read_mutations_unsafe();
67
+ const by_id = new Map<string, SessionMessage>();
68
+ const raw = await fs.readFile(this.messages_file_path, "utf8").catch(() => "");
69
+ for (const [index, line] of raw.split("\n").entries()) {
70
+ const value = line.trim();
71
+ if (!value) continue;
72
+ try {
73
+ const message = JSON.parse(value) as SessionMessage;
74
+ this.validate_message(message);
75
+ const previous = by_id.get(message.message_id);
76
+ if (!previous || message.revision > previous.revision) by_id.set(message.message_id, message);
77
+ } catch (error) {
78
+ throw new Error(`Invalid session message at line ${String(index + 1)}: ${error instanceof Error ? error.message : String(error)}`);
79
+ }
80
+ }
81
+ const draft = await this.read_assistant_message();
82
+ if (draft) {
83
+ const previous = by_id.get(draft.message_id);
84
+ if (!previous || draft.revision > previous.revision) by_id.set(draft.message_id, draft);
85
+ }
86
+ return [...by_id.values()].sort((left, right) => left.sequence - right.sequence);
58
87
  }
59
88
 
60
- /** 重放全部 mutation,得到当前 canonical 消息列表。 */
61
- async list_messages(): Promise<SessionMessage[]> {
62
- return reduce_session_messages(await this.list_mutations());
89
+ /** 读取当前运行中的 Assistant 草稿。 */
90
+ async read_assistant_message(): Promise<SessionAssistantMessage | null> {
91
+ try {
92
+ const value = await fs.readJson(this.assistant_message_file_path) as SessionAssistantMessage;
93
+ this.validate_message(value);
94
+ if (value.type !== "assistant" || value.status !== "streaming") return null;
95
+ return value;
96
+ } catch (error) {
97
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
98
+ if (error instanceof SyntaxError) throw error;
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ /** 原子覆盖运行中的 Assistant 草稿。 */
104
+ async write_assistant_message(message: SessionAssistantMessage): Promise<void> {
105
+ this.validate_message(message);
106
+ if (message.status !== "streaming") throw new Error("Assistant draft must be streaming");
107
+ await this.with_write_lock(async () => {
108
+ const current = await this.read_assistant_message();
109
+ if (!current || current.message_id !== message.message_id) {
110
+ throw new Error(`Assistant draft does not exist: ${message.message_id}`);
111
+ }
112
+ if (message.revision !== current.revision + 1) {
113
+ throw new Error(`Invalid Assistant draft revision: ${message.message_id}`);
114
+ }
115
+ await this.write_assistant_message_unsafe(message);
116
+ });
63
117
  }
64
118
 
65
- /**
66
- * 在跨进程文件锁内计算并追加 mutation。
67
- * 发布动作必须由调用方在此 Promise 成功后执行。
68
- */
69
- async commit(
70
- build_mutation: (state: SessionMessageCommitState) => SessionMessageMutation,
71
- ): Promise<SessionMessageMutation> {
119
+ /** 在写锁内分配 Message sequence 并创建唯一 Assistant 草稿。 */
120
+ async create_assistant_message(
121
+ build_message: (state: SessionMessageCommitState) => SessionAssistantMessage,
122
+ ): Promise<SessionAssistantMessage> {
72
123
  return await this.with_write_lock(async () => {
73
- const mutations = await this.read_mutations_unsafe();
74
- const messages = reduce_session_messages(mutations);
75
- const mutation = build_mutation({
76
- commit_sequence:
77
- (mutations[mutations.length - 1]?.commit_sequence || 0) + 1,
78
- message_sequence:
79
- messages.reduce(
80
- (value, message) => Math.max(value, message.sequence + 1),
81
- 1,
82
- ),
124
+ const current = await this.read_assistant_message();
125
+ if (current) throw new Error(`Assistant draft already exists: ${current.message_id}`);
126
+ const messages = await this.read_messages_unsafe();
127
+ const message = build_message({
128
+ message_sequence: messages.reduce((max, item) => Math.max(max, item.sequence + 1), 1),
83
129
  messages,
84
130
  });
85
- await fs.appendFile(
86
- this.messages_file_path,
87
- `${JSON.stringify(mutation)}\n`,
88
- "utf8",
89
- );
90
- return mutation;
131
+ this.validate_message(message);
132
+ if (message.status !== "streaming" || message.revision !== 1) {
133
+ throw new Error("New Assistant draft must be streaming at revision 1");
134
+ }
135
+ await this.write_assistant_message_unsafe(message);
136
+ return message;
91
137
  });
92
138
  }
93
139
 
94
- private async ensure_layout(): Promise<void> {
95
- await fs.ensureDir(path.dirname(this.messages_file_path));
96
- await fs.ensureFile(this.messages_file_path);
140
+ /** 创建新 Message 或追加已有 Message revision 快照。 */
141
+ async append_message(
142
+ build_message: (state: SessionMessageCommitState) => SessionMessage,
143
+ ): Promise<SessionMessage> {
144
+ return await this.with_write_lock(async () => {
145
+ const messages = await this.read_messages_unsafe();
146
+ const draft = await this.read_assistant_message();
147
+ const current_messages = draft ? [...messages, draft] : messages;
148
+ const message = build_message({
149
+ message_sequence: current_messages.reduce((max, item) => Math.max(max, item.sequence + 1), 1),
150
+ messages: current_messages,
151
+ });
152
+ this.validate_message(message);
153
+ await fs.appendFile(this.messages_file_path, `${JSON.stringify(message)}\n`, "utf8");
154
+ return message;
155
+ });
156
+ }
157
+
158
+ /** 将 Assistant 草稿最终追加为历史快照,并删除草稿文件。 */
159
+ async finalize_assistant_message(message: SessionAssistantMessage): Promise<void> {
160
+ this.validate_message(message);
161
+ if (message.status === "streaming") throw new Error("Final Assistant message cannot be streaming");
162
+ await this.with_write_lock(async () => {
163
+ const current = await this.read_assistant_message();
164
+ if (!current || current.message_id !== message.message_id) {
165
+ throw new Error(`Assistant draft does not exist: ${message.message_id}`);
166
+ }
167
+ if (message.revision !== current.revision + 1) {
168
+ throw new Error(`Invalid final Assistant revision: ${message.message_id}`);
169
+ }
170
+ await fs.appendFile(this.messages_file_path, `${JSON.stringify(message)}\n`, "utf8");
171
+ await fs.remove(this.assistant_message_file_path);
172
+ });
97
173
  }
98
174
 
99
- private async read_mutations_unsafe(): Promise<SessionMessageMutation[]> {
175
+ private async read_messages_unsafe(): Promise<SessionMessage[]> {
100
176
  const raw = await fs.readFile(this.messages_file_path, "utf8").catch(() => "");
101
- const mutations: SessionMessageMutation[] = [];
102
- const lines = raw.split("\n");
103
- for (let index = 0; index < lines.length; index += 1) {
104
- const line = lines[index]?.trim();
105
- if (!line) continue;
106
- try {
107
- const mutation = JSON.parse(line) as SessionMessageMutation;
108
- if (mutation.session_id !== this.session_id) {
109
- throw new Error("session_id mismatch");
177
+ const by_id = new Map<string, SessionMessage>();
178
+ for (const line of raw.split("\n")) {
179
+ if (!line.trim()) continue;
180
+ const message = JSON.parse(line) as SessionMessage;
181
+ this.validate_message(message);
182
+ const previous = by_id.get(message.message_id);
183
+ if (!previous || message.revision > previous.revision) by_id.set(message.message_id, message);
184
+ }
185
+ return [...by_id.values()].sort((left, right) => left.sequence - right.sequence);
186
+ }
187
+
188
+ private async write_assistant_message_unsafe(message: SessionAssistantMessage): Promise<void> {
189
+ const temporary_path = `${this.assistant_message_file_path}.${process.pid}.${Date.now()}.tmp`;
190
+ await fs.ensureDir(path.dirname(this.assistant_message_file_path));
191
+ await fs.writeJson(temporary_path, message, { spaces: 2 });
192
+ await fs.move(temporary_path, this.assistant_message_file_path, { overwrite: true });
193
+ }
194
+
195
+ private validate_message(message: SessionMessage): void {
196
+ if (!message || typeof message !== "object") throw new Error("message must be an object");
197
+ if (message.session_id !== this.session_id) throw new Error("session_id mismatch");
198
+ if (!message.message_id || !Number.isInteger(message.sequence) || !Number.isInteger(message.revision)) {
199
+ throw new Error("message identity, sequence and revision are required");
200
+ }
201
+ if (message.type === "assistant") {
202
+ const sequences = new Set<number>();
203
+ for (const part of message.parts) {
204
+ if (!Number.isInteger(part.sequence) || sequences.has(part.sequence)) {
205
+ throw new Error(`invalid Assistant part sequence: ${part.part_id}`);
110
206
  }
111
- mutations.push(mutation);
112
- } catch (error) {
113
- throw new Error(
114
- `Invalid session mutation at line ${String(index + 1)}: ${error instanceof Error ? error.message : String(error)}`,
115
- );
207
+ sequences.add(part.sequence);
116
208
  }
117
209
  }
118
- return mutations;
210
+ }
211
+
212
+ private async ensure_layout(): Promise<void> {
213
+ await fs.ensureDir(path.dirname(this.messages_file_path));
214
+ await fs.ensureFile(this.messages_file_path);
119
215
  }
120
216
 
121
217
  private async with_write_lock<T>(callback: () => Promise<T>): Promise<T> {
@@ -130,20 +226,12 @@ export class JsonlSessionMessageStore {
130
226
  await file.close();
131
227
  break;
132
228
  } catch (error) {
133
- const code = (error as NodeJS.ErrnoException).code;
134
- if (code !== "EEXIST") throw error;
229
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
135
230
  try {
136
231
  const lock_stat = await stat(this.lock_file_path);
137
- if (Date.now() - lock_stat.mtimeMs > stale_ms) {
138
- await fs.remove(this.lock_file_path);
139
- continue;
140
- }
141
- } catch {
142
- continue;
143
- }
144
- if (Date.now() - started_at > stale_ms * 2) {
145
- throw new Error(`Session message lock timeout: ${this.lock_file_path}`);
146
- }
232
+ if (Date.now() - lock_stat.mtimeMs > stale_ms) await fs.remove(this.lock_file_path);
233
+ } catch { /* 锁文件可能刚被释放。 */ }
234
+ if (Date.now() - started_at > stale_ms * 2) throw new Error(`Session message lock timeout: ${this.lock_file_path}`);
147
235
  await new Promise((resolve) => setTimeout(resolve, 40));
148
236
  }
149
237
  }
@@ -151,12 +239,8 @@ export class JsonlSessionMessageStore {
151
239
  return await callback();
152
240
  } finally {
153
241
  try {
154
- if ((await readFile(this.lock_file_path, "utf8")).trim() === token) {
155
- await fs.remove(this.lock_file_path);
156
- }
157
- } catch {
158
- // 锁文件已被清理时无需额外处理。
159
- }
242
+ if ((await readFile(this.lock_file_path, "utf8")).trim() === token) await fs.remove(this.lock_file_path);
243
+ } catch { /* 锁已被清理。 */ }
160
244
  }
161
245
  }
162
246
  }