@downcity/agent 1.1.217 → 1.1.222

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 (147) hide show
  1. package/bin/agent/local/services/AgentSessions.d.ts.map +1 -1
  2. package/bin/agent/local/services/AgentSessions.js +1 -4
  3. package/bin/agent/local/services/AgentSessions.js.map +1 -1
  4. package/bin/agent/remote/RemoteSession.d.ts +9 -3
  5. package/bin/agent/remote/RemoteSession.d.ts.map +1 -1
  6. package/bin/agent/remote/RemoteSession.js +19 -4
  7. package/bin/agent/remote/RemoteSession.js.map +1 -1
  8. package/bin/agent/remote/RemoteTransport.d.ts +7 -3
  9. package/bin/agent/remote/RemoteTransport.d.ts.map +1 -1
  10. package/bin/agent/remote/transports/HttpRemoteAgentTransport.d.ts +5 -2
  11. package/bin/agent/remote/transports/HttpRemoteAgentTransport.d.ts.map +1 -1
  12. package/bin/agent/remote/transports/HttpRemoteAgentTransport.js +25 -11
  13. package/bin/agent/remote/transports/HttpRemoteAgentTransport.js.map +1 -1
  14. package/bin/agent/remote/transports/RpcRemoteAgentTransport.d.ts +5 -2
  15. package/bin/agent/remote/transports/RpcRemoteAgentTransport.d.ts.map +1 -1
  16. package/bin/agent/remote/transports/RpcRemoteAgentTransport.js +5 -2
  17. package/bin/agent/remote/transports/RpcRemoteAgentTransport.js.map +1 -1
  18. package/bin/executor/types/SessionRecords.d.ts +2 -0
  19. package/bin/executor/types/SessionRecords.d.ts.map +1 -1
  20. package/bin/executor/types/SessionRecords.js.map +1 -1
  21. package/bin/index.d.ts +5 -2
  22. package/bin/index.d.ts.map +1 -1
  23. package/bin/index.js +1 -0
  24. package/bin/index.js.map +1 -1
  25. package/bin/rpc/Client.d.ts +11 -4
  26. package/bin/rpc/Client.d.ts.map +1 -1
  27. package/bin/rpc/Client.js +14 -3
  28. package/bin/rpc/Client.js.map +1 -1
  29. package/bin/session/Session.d.ts +13 -3
  30. package/bin/session/Session.d.ts.map +1 -1
  31. package/bin/session/Session.js +47 -18
  32. package/bin/session/Session.js.map +1 -1
  33. package/bin/session/recorder/JsonlSessionMessageStore.d.ts +45 -0
  34. package/bin/session/recorder/JsonlSessionMessageStore.d.ts.map +1 -0
  35. package/bin/session/recorder/JsonlSessionMessageStore.js +127 -0
  36. package/bin/session/recorder/JsonlSessionMessageStore.js.map +1 -0
  37. package/bin/session/recorder/SessionMessageCodec.d.ts +20 -0
  38. package/bin/session/recorder/SessionMessageCodec.d.ts.map +1 -0
  39. package/bin/session/recorder/SessionMessageCodec.js +252 -0
  40. package/bin/session/recorder/SessionMessageCodec.js.map +1 -0
  41. package/bin/session/recorder/SessionMessageReducer.d.ts +12 -0
  42. package/bin/session/recorder/SessionMessageReducer.d.ts.map +1 -0
  43. package/bin/session/recorder/SessionMessageReducer.js +109 -0
  44. package/bin/session/recorder/SessionMessageReducer.js.map +1 -0
  45. package/bin/session/recorder/SessionRecorder.d.ts +177 -0
  46. package/bin/session/recorder/SessionRecorder.d.ts.map +1 -0
  47. package/bin/session/recorder/SessionRecorder.js +610 -0
  48. package/bin/session/recorder/SessionRecorder.js.map +1 -0
  49. package/bin/session/recorder/SessionRecorderCompaction.d.ts +18 -0
  50. package/bin/session/recorder/SessionRecorderCompaction.d.ts.map +1 -0
  51. package/bin/session/recorder/SessionRecorderCompaction.js +141 -0
  52. package/bin/session/recorder/SessionRecorderCompaction.js.map +1 -0
  53. package/bin/session/recorder/SessionRecorderHistoryStore.d.ts +66 -0
  54. package/bin/session/recorder/SessionRecorderHistoryStore.d.ts.map +1 -0
  55. package/bin/session/recorder/SessionRecorderHistoryStore.js +195 -0
  56. package/bin/session/recorder/SessionRecorderHistoryStore.js.map +1 -0
  57. package/bin/session/runtime/SessionMessageEventHub.d.ts +15 -0
  58. package/bin/session/runtime/SessionMessageEventHub.d.ts.map +1 -0
  59. package/bin/session/runtime/SessionMessageEventHub.js +28 -0
  60. package/bin/session/runtime/SessionMessageEventHub.js.map +1 -0
  61. package/bin/session/runtime/SessionPromptRuntime.d.ts +7 -13
  62. package/bin/session/runtime/SessionPromptRuntime.d.ts.map +1 -1
  63. package/bin/session/runtime/SessionPromptRuntime.js +13 -13
  64. package/bin/session/runtime/SessionPromptRuntime.js.map +1 -1
  65. package/bin/session/services/SessionStateService.d.ts +7 -3
  66. package/bin/session/services/SessionStateService.d.ts.map +1 -1
  67. package/bin/session/services/SessionStateService.js +132 -72
  68. package/bin/session/services/SessionStateService.js.map +1 -1
  69. package/bin/session/services/SessionTurnService.d.ts +4 -0
  70. package/bin/session/services/SessionTurnService.d.ts.map +1 -1
  71. package/bin/session/services/SessionTurnService.js +82 -52
  72. package/bin/session/services/SessionTurnService.js.map +1 -1
  73. package/bin/session/services/SessionViewService.d.ts +8 -3
  74. package/bin/session/services/SessionViewService.d.ts.map +1 -1
  75. package/bin/session/services/SessionViewService.js +10 -5
  76. package/bin/session/services/SessionViewService.js.map +1 -1
  77. package/bin/session/storage/Persistence.d.ts +6 -0
  78. package/bin/session/storage/Persistence.d.ts.map +1 -1
  79. package/bin/session/storage/Persistence.js +7 -0
  80. package/bin/session/storage/Persistence.js.map +1 -1
  81. package/bin/session/storage/RuntimeSessionPort.d.ts +3 -2
  82. package/bin/session/storage/RuntimeSessionPort.d.ts.map +1 -1
  83. package/bin/session/storage/RuntimeSessionPort.js.map +1 -1
  84. package/bin/types/agent/SessionActor.d.ts +10 -4
  85. package/bin/types/agent/SessionActor.d.ts.map +1 -1
  86. package/bin/types/rpc/RpcProtocol.d.ts +20 -6
  87. package/bin/types/rpc/RpcProtocol.d.ts.map +1 -1
  88. package/bin/types/runtime/agent/AgentContext.d.ts +4 -3
  89. package/bin/types/runtime/agent/AgentContext.d.ts.map +1 -1
  90. package/bin/types/runtime/agent/AgentContext.js.map +1 -1
  91. package/bin/types/sdk/AgentSessionEvent.d.ts +2 -1
  92. package/bin/types/sdk/AgentSessionEvent.d.ts.map +1 -1
  93. package/bin/types/session/SessionMessage.d.ts +217 -0
  94. package/bin/types/session/SessionMessage.d.ts.map +1 -0
  95. package/bin/types/session/SessionMessage.js +8 -0
  96. package/bin/types/session/SessionMessage.js.map +1 -0
  97. package/bin/types/session/SessionMessageMutation.d.ts +92 -0
  98. package/bin/types/session/SessionMessageMutation.d.ts.map +1 -0
  99. package/bin/types/session/SessionMessageMutation.js +21 -0
  100. package/bin/types/session/SessionMessageMutation.js.map +1 -0
  101. package/package.json +3 -3
  102. package/scripts/agent-env.test.mjs +4 -4
  103. package/scripts/session-action-message.test.mjs +98 -27
  104. package/scripts/session-list-title.test.mjs +6 -19
  105. package/scripts/session-prompt-runtime.test.mjs +4 -16
  106. package/scripts/session-recorder.test.mjs +245 -0
  107. package/scripts/session-title-event.test.mjs +10 -18
  108. package/src/agent/local/services/AgentSessions.ts +1 -3
  109. package/src/agent/remote/RemoteSession.ts +35 -7
  110. package/src/agent/remote/RemoteTransport.ts +17 -6
  111. package/src/agent/remote/transports/HttpRemoteAgentTransport.ts +45 -13
  112. package/src/agent/remote/transports/RpcRemoteAgentTransport.ts +19 -6
  113. package/src/executor/types/SessionRecords.ts +2 -0
  114. package/src/index.ts +23 -5
  115. package/src/rpc/Client.ts +29 -8
  116. package/src/session/Session.ts +65 -31
  117. package/src/session/recorder/JsonlSessionMessageStore.ts +162 -0
  118. package/src/session/recorder/SessionMessageCodec.ts +279 -0
  119. package/src/session/recorder/SessionMessageReducer.ts +155 -0
  120. package/src/session/recorder/SessionRecorder.ts +812 -0
  121. package/src/session/recorder/SessionRecorderCompaction.ts +187 -0
  122. package/src/session/recorder/SessionRecorderHistoryStore.ts +276 -0
  123. package/src/session/runtime/SessionMessageEventHub.ts +35 -0
  124. package/src/session/runtime/SessionPromptRuntime.ts +23 -24
  125. package/src/session/services/SessionStateService.ts +151 -80
  126. package/src/session/services/SessionTurnService.ts +88 -59
  127. package/src/session/services/SessionViewService.ts +25 -13
  128. package/src/session/storage/Persistence.ts +13 -0
  129. package/src/session/storage/RuntimeSessionPort.ts +6 -4
  130. package/src/types/agent/SessionActor.ts +26 -6
  131. package/src/types/rpc/RpcProtocol.ts +20 -6
  132. package/src/types/runtime/agent/AgentContext.ts +8 -2
  133. package/src/types/sdk/AgentSessionEvent.ts +2 -0
  134. package/src/types/session/SessionMessage.ts +247 -0
  135. package/src/types/session/SessionMessageMutation.ts +121 -0
  136. package/tsconfig.tsbuildinfo +1 -1
  137. package/bin/session/SessionEventMapper.d.ts +0 -32
  138. package/bin/session/SessionEventMapper.d.ts.map +0 -1
  139. package/bin/session/SessionEventMapper.js +0 -131
  140. package/bin/session/SessionEventMapper.js.map +0 -1
  141. package/bin/types/sdk/InternalUiChunkEvent.d.ts +0 -100
  142. package/bin/types/sdk/InternalUiChunkEvent.d.ts.map +0 -1
  143. package/bin/types/sdk/InternalUiChunkEvent.js +0 -9
  144. package/bin/types/sdk/InternalUiChunkEvent.js.map +0 -1
  145. package/scripts/session-history-archive.test.mjs +0 -188
  146. package/src/session/SessionEventMapper.ts +0 -148
  147. package/src/types/sdk/InternalUiChunkEvent.ts +0 -108
@@ -113,12 +113,15 @@ test("session.set only persists and publishes model-switching actions when model
113
113
  });
114
114
  const events = [];
115
115
  const unsubscribe = session.subscribe((event) => {
116
- if (event.type === "action") events.push(event);
116
+ if (
117
+ event.variant === "message" && event.type === "action"
118
+ ) events.push(event.message);
117
119
  });
118
120
 
119
121
  await session.set({
122
+ modelId: "test-model",
120
123
  model: {
121
- modelId: "test-model",
124
+ modelId: "test-model-label-v1",
122
125
  provider: "test",
123
126
  },
124
127
  });
@@ -126,8 +129,9 @@ test("session.set only persists and publishes model-switching actions when model
126
129
  assert.deepEqual(events, []);
127
130
 
128
131
  await session.set({
132
+ modelId: "test-model",
129
133
  model: {
130
- modelId: "test-model",
134
+ modelId: "test-model-label-v2",
131
135
  provider: "test",
132
136
  },
133
137
  });
@@ -135,26 +139,32 @@ test("session.set only persists and publishes model-switching actions when model
135
139
  assert.deepEqual(events, []);
136
140
 
137
141
  await session.set({
142
+ modelId: "next-test-model",
138
143
  model: {
139
- modelId: "next-test-model",
144
+ modelId: "next-test-model-label",
140
145
  provider: "test",
141
146
  },
142
147
  });
143
148
  unsubscribe();
144
149
 
145
150
  assert.deepEqual(
146
- events.map((event) => `${event.title}:${event.state}`),
147
- ["Switching session model:running", "Session model switched:completed"],
151
+ events.map((event) => `${event.title}:${event.status}`),
152
+ [
153
+ "Switching session model from test-model-label-v2 to next-test-model-label:running",
154
+ "Session model switched from test-model-label-v2 to next-test-model-label:completed",
155
+ ],
148
156
  );
149
- assert.equal(new Set(events.map((event) => event.id)).size, 1);
157
+ assert.equal(new Set(events.map((event) => event.message_id)).size, 1);
150
158
 
151
- const records = await session.records({ view: "timeline" });
152
- const action_events = records.items.filter(
153
- (item) => item.role === "action",
159
+ const messages = await session.messages();
160
+ const action_events = messages.items.filter(
161
+ (item) => item.type === "action",
154
162
  );
155
163
  assert.deepEqual(
156
- action_events.map((event) => `${event.actionTitle}:${event.actionState}`),
157
- ["Session model switched:completed"],
164
+ action_events.map((event) => `${event.title}:${event.status}`),
165
+ [
166
+ "Session model switched from test-model-label-v2 to next-test-model-label:completed",
167
+ ],
158
168
  );
159
169
  } finally {
160
170
  await agent.dispose();
@@ -177,9 +187,9 @@ test("default model initialization does not persist model-switching actions", as
177
187
  const session = await agent.sessions.create({
178
188
  sessionId: "model_init_action_session",
179
189
  });
180
- const records = await session.records({ view: "timeline" });
181
- const action_events = records.items.filter(
182
- (item) => item.role === "action",
190
+ const messages = await session.messages();
191
+ const action_events = messages.items.filter(
192
+ (item) => item.type === "action",
183
193
  );
184
194
  assert.deepEqual(action_events, []);
185
195
  } finally {
@@ -237,6 +247,67 @@ test("session model id persists and restores through the host resolver", async (
237
247
  }
238
248
  });
239
249
 
250
+ test("runtime restoration does not publish a model switch when model id is unchanged", async () => {
251
+ const agent_path = await fs.mkdtemp(
252
+ path.join(os.tmpdir(), "downcity-agent-session-model-runtime-restore-"),
253
+ );
254
+ const first_agent = new Agent({
255
+ id: "session_model_runtime_restore_agent",
256
+ path: agent_path,
257
+ model: {
258
+ modelId: "default-model",
259
+ provider: "test",
260
+ },
261
+ model_id: "default-model",
262
+ resolve_model: async (model_id) => ({
263
+ modelId: model_id,
264
+ provider: "test",
265
+ }),
266
+ });
267
+ try {
268
+ const session = await first_agent.sessions.create({
269
+ sessionId: "runtime-restored-model-session",
270
+ });
271
+ await session.set({ modelId: "session-model" });
272
+ } finally {
273
+ await first_agent.dispose();
274
+ }
275
+
276
+ let resolve_count = 0;
277
+ const restored_agent = new Agent({
278
+ id: "session_model_runtime_restore_agent",
279
+ path: agent_path,
280
+ model: {
281
+ modelId: "other-default-model",
282
+ provider: "test",
283
+ },
284
+ model_id: "other-default-model",
285
+ resolve_model: async (model_id) => {
286
+ resolve_count += 1;
287
+ return {
288
+ modelId: model_id,
289
+ provider: "test",
290
+ };
291
+ },
292
+ });
293
+ try {
294
+ const session_port = restored_agent
295
+ .getContext()
296
+ .session.get("runtime-restored-model-session");
297
+ const events = [];
298
+ const unsubscribe = session_port.subscribe((event) => {
299
+ if (event.type === "action") events.push(event);
300
+ });
301
+ await session_port.stop();
302
+ unsubscribe();
303
+
304
+ assert.equal(resolve_count, 1);
305
+ assert.deepEqual(events, []);
306
+ } finally {
307
+ await restored_agent.dispose();
308
+ }
309
+ });
310
+
240
311
  test("session.fork persists and publishes history-forking actions on source session", async () => {
241
312
  const agent_path = await fs.mkdtemp(
242
313
  path.join(os.tmpdir(), "downcity-agent-history-forking-"),
@@ -259,32 +330,32 @@ test("session.fork persists and publishes history-forking actions on source sess
259
330
 
260
331
  const events = [];
261
332
  const unsubscribe = session.subscribe((event) => {
262
- if (event.type === "action") {
263
- events.push(event);
264
- }
333
+ if (
334
+ event.variant === "message" && event.type === "action"
335
+ ) events.push(event.message);
265
336
  });
266
337
  const forked = await session.fork();
267
338
  unsubscribe();
268
339
 
269
340
  assert.deepEqual(
270
- events.map((event) => `${event.title}:${event.state}`),
341
+ events.map((event) => `${event.title}:${event.status}`),
271
342
  ["Forking session records:running", "Session records forked:completed"],
272
343
  );
273
- assert.equal(new Set(events.map((event) => event.id)).size, 1);
344
+ assert.equal(new Set(events.map((event) => event.message_id)).size, 1);
274
345
  assert.notEqual(forked.id, session.id);
275
346
 
276
- const source_records = await session.records({ view: "timeline" });
277
- const source_fork_actions = source_records.items.filter(
278
- (item) => item.role === "action" && item.actionTitle === "Session records forked",
347
+ const source_messages = await session.messages();
348
+ const source_fork_actions = source_messages.items.filter(
349
+ (item) => item.type === "action" && item.title === "Session records forked",
279
350
  );
280
351
  assert.deepEqual(
281
- source_fork_actions.map((event) => event.actionState),
352
+ source_fork_actions.map((event) => event.status),
282
353
  ["completed"],
283
354
  );
284
355
 
285
- const forked_records = await forked.records({ view: "timeline" });
286
- const forked_model_actions = forked_records.items.filter(
287
- (item) => item.role === "action" && item.actionTitle === "Session model switched",
356
+ const forked_messages = await forked.messages();
357
+ const forked_model_actions = forked_messages.items.filter(
358
+ (item) => item.type === "action" && item.title.startsWith("Session model switched from "),
288
359
  );
289
360
  assert.equal(forked_model_actions.length, 0);
290
361
  } finally {
@@ -141,7 +141,7 @@ test("list_sessions returns persisted title from active session metadata", async
141
141
  }
142
142
  });
143
143
 
144
- test("list_sessions rebuilds stale metadata after external history changes", async () => {
144
+ test("list_sessions reflects canonical Recorder history changes", async () => {
145
145
  const { agent, collection, session } = await create_agent_with_titled_session({
146
146
  tmp_prefix: "downcity-agent-session-summary-repair-",
147
147
  agent_id: "summary_repair_agent",
@@ -162,31 +162,18 @@ test("list_sessions rebuilds stale metadata after external history changes", asy
162
162
  const meta_path = path.join(messages_dir, "meta.json");
163
163
 
164
164
  try {
165
- await fs.appendFile(
166
- messages_path,
167
- `${JSON.stringify({
168
- id: "external_assistant",
169
- role: "assistant",
170
- metadata: {
171
- v: 1,
172
- ts: Date.now(),
173
- sessionId: session.id,
174
- source: "egress",
175
- kind: "normal",
176
- },
177
- parts: [{ type: "text", text: "Externally appended history" }],
178
- })}\n`,
179
- "utf8",
180
- );
165
+ await session.append_assistant_message({
166
+ text: "Recorder appended history",
167
+ });
181
168
 
182
169
  const page = await collection.list();
183
170
  const repaired_meta = JSON.parse(await fs.readFile(meta_path, "utf8"));
184
171
  const history_stat = await fs.stat(messages_path);
185
172
 
186
173
  assert.equal(page.items[0].messageCount, 2);
187
- assert.equal(page.items[0].previewText, "Externally appended history");
174
+ assert.equal(page.items[0].previewText, "Recorder appended history");
188
175
  assert.equal(repaired_meta.messageCount, 2);
189
- assert.equal(repaired_meta.previewText, "Externally appended history");
176
+ assert.equal(repaired_meta.previewText, "Recorder appended history");
190
177
  assert.equal(repaired_meta.historyBytes, history_stat.size);
191
178
  } finally {
192
179
  await agent.dispose();
@@ -76,7 +76,6 @@ async function isSettled(promise) {
76
76
  test("SessionPromptRuntime merges queued prompts at the next step boundary", async () => {
77
77
  const events = [];
78
78
  const persisted = [];
79
- const steer_actions = [];
80
79
  const merge_lifecycle = [];
81
80
  const executionFinished = createDeferred();
82
81
  let stepMerge = null;
@@ -92,10 +91,7 @@ test("SessionPromptRuntime merges queued prompts at the next step boundary", asy
92
91
  merge_lifecycle.push(`message:${input.query}`);
93
92
  return message;
94
93
  },
95
- emit_steer_action: async (input) => {
96
- steer_actions.push(input);
97
- merge_lifecycle.push(`action:${input.message.parts[0]?.text}`);
98
- },
94
+ appendErrorMessage: async () => {},
99
95
  executeTurn: async (input) => {
100
96
  stepMerge = input.onStepMerge;
101
97
  await executionFinished.promise;
@@ -126,17 +122,9 @@ test("SessionPromptRuntime merges queued prompts at the next step boundary", asy
126
122
  persisted.map((message) => message.parts[0]?.text),
127
123
  ["first", "second"],
128
124
  );
129
- assert.deepEqual(
130
- steer_actions.map((action) => ({
131
- turn_id: action.turn_id,
132
- text: action.message.parts[0]?.text,
133
- })),
134
- [{ turn_id: firstTurn.id, text: "second" }],
135
- );
136
125
  assert.deepEqual(merge_lifecycle, [
137
126
  "message:first",
138
127
  "message:second",
139
- "action:second",
140
128
  ]);
141
129
 
142
130
  executionFinished.resolve();
@@ -163,7 +151,7 @@ test("SessionPromptRuntime moves unmerged prompts into the next turn", async ()
163
151
  createAndPersistUserMessage: async (input) => {
164
152
  return createUserMessage(input.query, events.length + 1);
165
153
  },
166
- emit_steer_action: async () => {},
154
+ appendErrorMessage: async () => {},
167
155
  executeTurn: async (input) => {
168
156
  const deferred = createDeferred();
169
157
  finishQueue.push({
@@ -221,7 +209,7 @@ test("SessionPromptRuntime stops current turn and cancels unmerged queued prompt
221
209
  createAndPersistUserMessage: async (input) => {
222
210
  return createUserMessage(input.query, events.length + 1);
223
211
  },
224
- emit_steer_action: async () => {},
212
+ appendErrorMessage: async () => {},
225
213
  executeTurn: async (input) => {
226
214
  await new Promise((resolve) => {
227
215
  input.abortSignal.addEventListener("abort", resolve, { once: true });
@@ -278,7 +266,7 @@ test("SessionPromptRuntime does not synthesize assistant text when stopped befor
278
266
  createAndPersistUserMessage: async (input) => {
279
267
  return createUserMessage(input.query, 1);
280
268
  },
281
- emit_steer_action: async () => {},
269
+ appendErrorMessage: async () => {},
282
270
  executeTurn: async (input) => {
283
271
  await new Promise((resolve) => {
284
272
  input.abortSignal.addEventListener("abort", resolve, { once: true });
@@ -0,0 +1,245 @@
1
+ /**
2
+ * @file 验证 SessionRecorder 单一 Mutation 日志与实时/历史一致性。
3
+ */
4
+
5
+ import test from "node:test";
6
+ import assert from "node:assert/strict";
7
+ import fs from "node:fs/promises";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+
11
+ import { JsonlSessionMessageStore } from "../bin/session/recorder/JsonlSessionMessageStore.js";
12
+ import { SessionRecorder } from "../bin/session/recorder/SessionRecorder.js";
13
+ import { SessionRecorderHistoryStore } from "../bin/session/recorder/SessionRecorderHistoryStore.js";
14
+ import { MockLanguageModelV3 } from "ai/test";
15
+
16
+ async function create_recorder(session_id = "session-recorder-test") {
17
+ const root_path = await fs.mkdtemp(
18
+ path.join(os.tmpdir(), "downcity-session-recorder-"),
19
+ );
20
+ const file_path = path.join(root_path, "messages.jsonl");
21
+ const events = [];
22
+ const recorder = new SessionRecorder({
23
+ session_id,
24
+ store: new JsonlSessionMessageStore({ session_id, file_path }),
25
+ publish: (mutation) => events.push(mutation),
26
+ });
27
+ await recorder.initialize();
28
+ return { recorder, events, file_path };
29
+ }
30
+
31
+ test("每个 assistant delta 都先持久化为独立 Mutation 再发布", async () => {
32
+ const { recorder, events, file_path } = await create_recorder();
33
+ await recorder.append_user_message({
34
+ turn_id: "turn-1",
35
+ input_type: "prompt",
36
+ parts: [{
37
+ part_id: "user-text-1",
38
+ type: "text",
39
+ text: "你好",
40
+ state: "done",
41
+ }],
42
+ });
43
+ const writer = await recorder.open_assistant_message({
44
+ turn_id: "turn-1",
45
+ segment_index: 1,
46
+ });
47
+ await writer.apply_chunk({ type: "text-start", id: "text-1" });
48
+ await writer.apply_chunk({ type: "text-delta", id: "text-1", delta: "你" });
49
+ await writer.apply_chunk({ type: "text-delta", id: "text-1", delta: "好" });
50
+ await writer.apply_chunk({ type: "text-end", id: "text-1" });
51
+ await writer.complete();
52
+
53
+ const changes = await recorder.list_message_changes({
54
+ after_commit_sequence: 0,
55
+ });
56
+ const persisted_lines = (await fs.readFile(file_path, "utf8"))
57
+ .trim()
58
+ .split("\n");
59
+ assert.equal(persisted_lines.length, changes.items.length);
60
+ assert.deepEqual(
61
+ changes.items.map((item) => item.commit_sequence),
62
+ events.map((item) => item.commit_sequence),
63
+ );
64
+ assert.deepEqual(
65
+ changes.items.map((item) => `${item.variant}:${item.type}`),
66
+ [
67
+ "message:user",
68
+ "message:assistant",
69
+ "part:text",
70
+ "delta:text",
71
+ "delta:text",
72
+ "part:text",
73
+ "message:assistant",
74
+ ],
75
+ );
76
+ assert.deepEqual(
77
+ changes.items
78
+ .filter((item) => item.variant === "delta")
79
+ .map((item) => item.delta),
80
+ ["你", "好"],
81
+ );
82
+
83
+ const page = await recorder.list_messages();
84
+ assert.deepEqual(page.items.map((item) => item.type), ["user", "assistant"]);
85
+ const assistant = page.items[1];
86
+ assert.equal(assistant.status, "completed");
87
+ assert.equal(assistant.parts[0].text, "你好");
88
+ });
89
+
90
+ test("tool 与 action 在原 Message 和 part 上更新", async () => {
91
+ const { recorder } = await create_recorder("tool-action-test");
92
+ const writer = await recorder.open_assistant_message({
93
+ turn_id: "turn-1",
94
+ segment_index: 1,
95
+ });
96
+ await writer.apply_chunk({
97
+ type: "tool-input-start",
98
+ toolCallId: "call-1",
99
+ toolName: "search",
100
+ });
101
+ await writer.apply_chunk({
102
+ type: "tool-input-available",
103
+ toolCallId: "call-1",
104
+ toolName: "search",
105
+ input: { query: "downcity" },
106
+ });
107
+ await writer.apply_chunk({
108
+ type: "tool-output-available",
109
+ toolCallId: "call-1",
110
+ output: { count: 1 },
111
+ });
112
+ await writer.complete();
113
+
114
+ const action = await recorder.open_action_message({
115
+ message_id: "action-1",
116
+ action_type: "compact",
117
+ title: "Compacting",
118
+ });
119
+ const running = recorder.get_message("action-1");
120
+ await action.complete({ title: "Compacted" });
121
+ const completed = recorder.get_message("action-1");
122
+
123
+ const assistant = (await recorder.list_messages()).items[0];
124
+ assert.equal(assistant.parts.length, 1);
125
+ assert.equal(assistant.parts[0].state, "completed");
126
+ assert.deepEqual(assistant.parts[0].output, { count: 1 });
127
+ assert.equal(running.sequence, completed.sequence);
128
+ assert.equal(completed.revision, running.revision + 1);
129
+ assert.equal(completed.status, "completed");
130
+ });
131
+
132
+ test("snapshot、changes 分页和重启恢复保持单调顺序", async () => {
133
+ const { recorder, file_path } = await create_recorder("recovery-test");
134
+ await recorder.append_user_message({
135
+ turn_id: "turn-1",
136
+ input_type: "prompt",
137
+ parts: [{ part_id: "u1", type: "text", text: "one", state: "done" }],
138
+ });
139
+ await recorder.open_assistant_message({
140
+ turn_id: "turn-1",
141
+ segment_index: 1,
142
+ });
143
+ await recorder.open_action_message({
144
+ message_id: "running-action",
145
+ action_type: "fork",
146
+ title: "Forking",
147
+ });
148
+
149
+ const recovered_events = [];
150
+ const recovered = new SessionRecorder({
151
+ session_id: "recovery-test",
152
+ store: new JsonlSessionMessageStore({
153
+ session_id: "recovery-test",
154
+ file_path,
155
+ }),
156
+ publish: (mutation) => recovered_events.push(mutation),
157
+ });
158
+ await recovered.initialize();
159
+
160
+ const first_page = await recovered.list_messages({ limit: 2 });
161
+ const second_page = await recovered.list_messages({
162
+ limit: 2,
163
+ cursor: first_page.next_cursor,
164
+ });
165
+ assert.equal(first_page.items.length, 2);
166
+ assert.equal(second_page.items.length, 1);
167
+ assert.ok(first_page.items[1].sequence < second_page.items[0].sequence);
168
+
169
+ const messages = [...first_page.items, ...second_page.items];
170
+ assert.equal(messages[1].status, "stopped");
171
+ assert.equal(messages[2].status, "failed");
172
+ assert.deepEqual(
173
+ recovered_events.map((item) => `${item.variant}:${item.type}`),
174
+ ["message:assistant", "message:action"],
175
+ );
176
+
177
+ const first_changes = await recovered.list_message_changes({
178
+ after_commit_sequence: 0,
179
+ limit: 2,
180
+ });
181
+ const next_changes = await recovered.list_message_changes({
182
+ after_commit_sequence: first_changes.next_commit_sequence,
183
+ limit: 100,
184
+ });
185
+ assert.equal(first_changes.has_more, true);
186
+ assert.ok(
187
+ first_changes.items.at(-1).commit_sequence <
188
+ next_changes.items[0].commit_sequence,
189
+ );
190
+ });
191
+
192
+ test("compact 追加 internal summary 且不重写 Mutation 日志", async () => {
193
+ const { recorder, file_path } = await create_recorder("compact-test");
194
+ for (let index = 1; index <= 6; index += 1) {
195
+ await recorder.append_user_message({
196
+ turn_id: `turn-${String(index)}`,
197
+ input_type: "prompt",
198
+ parts: [{
199
+ part_id: `user-${String(index)}`,
200
+ type: "text",
201
+ text: `message ${String(index)}`,
202
+ state: "done",
203
+ }],
204
+ });
205
+ }
206
+ const before = await fs.readFile(file_path, "utf8");
207
+ const history_store = new SessionRecorderHistoryStore({
208
+ session_id: "compact-test",
209
+ recorder,
210
+ });
211
+ const model = new MockLanguageModelV3({
212
+ modelId: "compact-model",
213
+ doGenerate: async () => ({
214
+ content: [{ type: "text", text: "Compact summary" }],
215
+ finishReason: { unified: "stop", raw: "stop" },
216
+ usage: {
217
+ inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
218
+ outputTokens: { total: 0, text: 0, reasoning: 0 },
219
+ },
220
+ warnings: [],
221
+ }),
222
+ });
223
+ const result = await history_store.compact({
224
+ model,
225
+ system: [],
226
+ keepLastMessages: 2,
227
+ maxInputTokensApprox: 1,
228
+ archiveOnCompact: false,
229
+ compactRatio: 0.5,
230
+ });
231
+ assert.equal(result.compacted, true);
232
+
233
+ const after = await fs.readFile(file_path, "utf8");
234
+ assert.equal(after.startsWith(before), true);
235
+ const all = await recorder.list_messages({
236
+ limit: 100,
237
+ include_internal: true,
238
+ });
239
+ const summary = all.items.find(
240
+ (message) => message.type === "assistant" && message.kind === "summary",
241
+ );
242
+ assert.equal(summary.visibility, "internal");
243
+ assert.equal(summary.summary_through_message_id, all.items[2].message_id);
244
+ assert.equal((await history_store.list_records()).length, 4);
245
+ });
@@ -110,11 +110,13 @@ test("Session keeps title empty when no model is available", async () => {
110
110
  text: "Use shell tools to inspect the current workspace",
111
111
  });
112
112
 
113
- const title_event = events.find((event) => event.type === "session-title");
114
- assert.equal(title_event, undefined);
115
-
116
- const records = await session.records();
117
- assert.equal(records.session.title, undefined);
113
+ assert.equal(
114
+ events.some(
115
+ (event) => event.variant === "message" && event.type === "user",
116
+ ),
117
+ true,
118
+ );
119
+ assert.equal((await session.get_info()).title, undefined);
118
120
  } finally {
119
121
  unsubscribe();
120
122
  await agent.dispose();
@@ -137,8 +139,7 @@ test("Session logs title generation failure without blocking the session", async
137
139
  text: "Diagnose why session title generation is flaky",
138
140
  });
139
141
 
140
- const records = await session.records();
141
- assert.equal(records.session.title, undefined);
142
+ assert.equal((await session.get_info()).title, undefined);
142
143
 
143
144
  await agent.getLogger().saveAllLogs();
144
145
  const log_lines = await read_log_lines(agent_path);
@@ -185,8 +186,7 @@ test("Session retries title generation after model becomes available", async ()
185
186
  text: "Investigate flaky session title generation in the SDK",
186
187
  });
187
188
 
188
- const history_before_model = await session.records();
189
- assert.equal(history_before_model.session.title, undefined);
189
+ assert.equal((await session.get_info()).title, undefined);
190
190
 
191
191
  await session.set({
192
192
  model: create_mock_title_model("排查 session 标题"),
@@ -195,15 +195,7 @@ test("Session retries title generation after model becomes available", async ()
195
195
  text: "Need another prompt to trigger the retry path",
196
196
  });
197
197
 
198
- const title_event = events.find((event) => event.type === "session-title");
199
- assert.deepEqual(title_event, {
200
- type: "session-title",
201
- sessionId: session.id,
202
- title: "排查 session 标题",
203
- });
204
-
205
- const records = await session.records();
206
- assert.equal(records.session.title, "排查 session 标题");
198
+ assert.equal((await session.get_info()).title, "排查 session 标题");
207
199
  } finally {
208
200
  unsubscribe();
209
201
  await agent.dispose();
@@ -358,9 +358,7 @@ export class AgentSessions implements AgentSessionsApi<AgentSession> {
358
358
  ): Promise<void> {
359
359
  if (this.configured_session_ids.has(session.id)) return;
360
360
  const persisted_model_id = String(session.config.modelId || "").trim();
361
- if (persisted_model_id && this.resolve_model) {
362
- await session.set({ modelId: persisted_model_id });
363
- } else if (this.default_model) {
361
+ if (!persisted_model_id && this.default_model) {
364
362
  await session.set({
365
363
  model: this.default_model,
366
364
  ...(this.default_model_id ? { modelId: this.default_model_id } : {}),