@downcity/agent 1.1.217 → 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.
- package/bin/agent/local/Agent.d.ts +1 -34
- package/bin/agent/local/Agent.d.ts.map +1 -1
- package/bin/agent/local/Agent.js +1 -44
- package/bin/agent/local/Agent.js.map +1 -1
- package/bin/agent/local/services/AgentAssemblyService.d.ts.map +1 -1
- package/bin/agent/local/services/AgentAssemblyService.js +35 -2
- package/bin/agent/local/services/AgentAssemblyService.js.map +1 -1
- package/bin/agent/local/services/AgentSessions.d.ts +6 -0
- package/bin/agent/local/services/AgentSessions.d.ts.map +1 -1
- package/bin/agent/local/services/AgentSessions.js +43 -4
- package/bin/agent/local/services/AgentSessions.js.map +1 -1
- package/bin/agent/remote/RemoteAgent.d.ts +1 -35
- package/bin/agent/remote/RemoteAgent.d.ts.map +1 -1
- package/bin/agent/remote/RemoteAgent.js +1 -37
- package/bin/agent/remote/RemoteAgent.js.map +1 -1
- package/bin/agent/remote/RemoteSession.d.ts +26 -41
- package/bin/agent/remote/RemoteSession.d.ts.map +1 -1
- package/bin/agent/remote/RemoteSession.js +75 -107
- package/bin/agent/remote/RemoteSession.js.map +1 -1
- package/bin/agent/remote/RemoteTransport.d.ts +15 -27
- package/bin/agent/remote/RemoteTransport.d.ts.map +1 -1
- package/bin/agent/remote/transports/HttpRemoteAgentTransport.d.ts +10 -21
- package/bin/agent/remote/transports/HttpRemoteAgentTransport.d.ts.map +1 -1
- package/bin/agent/remote/transports/HttpRemoteAgentTransport.js +24 -46
- package/bin/agent/remote/transports/HttpRemoteAgentTransport.js.map +1 -1
- package/bin/agent/remote/transports/RpcRemoteAgentTransport.d.ts +10 -20
- package/bin/agent/remote/transports/RpcRemoteAgentTransport.d.ts.map +1 -1
- package/bin/agent/remote/transports/RpcRemoteAgentTransport.js +10 -16
- package/bin/agent/remote/transports/RpcRemoteAgentTransport.js.map +1 -1
- package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js +2 -2
- package/bin/executor/store/history/jsonl/JsonlSessionHistoryStore.js.map +1 -1
- package/bin/executor/types/SessionHistoryPaths.d.ts +1 -1
- package/bin/executor/types/SessionRecords.d.ts +2 -0
- package/bin/executor/types/SessionRecords.d.ts.map +1 -1
- package/bin/executor/types/SessionRecords.js.map +1 -1
- package/bin/index.d.ts +6 -3
- package/bin/index.d.ts.map +1 -1
- package/bin/index.js +1 -0
- package/bin/index.js.map +1 -1
- package/bin/rpc/Client.d.ts +12 -34
- package/bin/rpc/Client.d.ts.map +1 -1
- package/bin/rpc/Client.js +31 -70
- package/bin/rpc/Client.js.map +1 -1
- package/bin/session/Session.d.ts +22 -4
- package/bin/session/Session.d.ts.map +1 -1
- package/bin/session/Session.js +95 -21
- package/bin/session/Session.js.map +1 -1
- package/bin/session/browse/Browse.d.ts.map +1 -1
- package/bin/session/browse/Browse.js +49 -18
- package/bin/session/browse/Browse.js.map +1 -1
- package/bin/session/recorder/JsonlSessionMessageStore.d.ts +51 -0
- package/bin/session/recorder/JsonlSessionMessageStore.d.ts.map +1 -0
- package/bin/session/recorder/JsonlSessionMessageStore.js +231 -0
- package/bin/session/recorder/JsonlSessionMessageStore.js.map +1 -0
- package/bin/session/recorder/SessionMessageCodec.d.ts +20 -0
- package/bin/session/recorder/SessionMessageCodec.d.ts.map +1 -0
- package/bin/session/recorder/SessionMessageCodec.js +257 -0
- package/bin/session/recorder/SessionMessageCodec.js.map +1 -0
- package/bin/session/recorder/SessionRecorder.d.ts +186 -0
- package/bin/session/recorder/SessionRecorder.d.ts.map +1 -0
- package/bin/session/recorder/SessionRecorder.js +655 -0
- package/bin/session/recorder/SessionRecorder.js.map +1 -0
- package/bin/session/recorder/SessionRecorderCompaction.d.ts +18 -0
- package/bin/session/recorder/SessionRecorderCompaction.d.ts.map +1 -0
- package/bin/session/recorder/SessionRecorderCompaction.js +142 -0
- package/bin/session/recorder/SessionRecorderCompaction.js.map +1 -0
- package/bin/session/recorder/SessionRecorderHistoryStore.d.ts +66 -0
- package/bin/session/recorder/SessionRecorderHistoryStore.d.ts.map +1 -0
- package/bin/session/recorder/SessionRecorderHistoryStore.js +195 -0
- package/bin/session/recorder/SessionRecorderHistoryStore.js.map +1 -0
- package/bin/session/runtime/SessionEventHub.d.ts +12 -5
- package/bin/session/runtime/SessionEventHub.d.ts.map +1 -1
- package/bin/session/runtime/SessionEventHub.js +11 -6
- package/bin/session/runtime/SessionEventHub.js.map +1 -1
- package/bin/session/runtime/SessionPromptRuntime.d.ts +9 -15
- package/bin/session/runtime/SessionPromptRuntime.d.ts.map +1 -1
- package/bin/session/runtime/SessionPromptRuntime.js +48 -30
- package/bin/session/runtime/SessionPromptRuntime.js.map +1 -1
- package/bin/session/services/SessionStateService.d.ts +9 -5
- package/bin/session/services/SessionStateService.d.ts.map +1 -1
- package/bin/session/services/SessionStateService.js +138 -74
- package/bin/session/services/SessionStateService.js.map +1 -1
- package/bin/session/services/SessionTurnService.d.ts +6 -7
- package/bin/session/services/SessionTurnService.d.ts.map +1 -1
- package/bin/session/services/SessionTurnService.js +84 -60
- package/bin/session/services/SessionTurnService.js.map +1 -1
- package/bin/session/services/SessionViewService.d.ts +8 -3
- package/bin/session/services/SessionViewService.d.ts.map +1 -1
- package/bin/session/services/SessionViewService.js +10 -5
- package/bin/session/services/SessionViewService.js.map +1 -1
- package/bin/session/storage/Paths.d.ts +1 -1
- package/bin/session/storage/Paths.d.ts.map +1 -1
- package/bin/session/storage/Paths.js +2 -2
- package/bin/session/storage/Paths.js.map +1 -1
- package/bin/session/storage/Persistence.d.ts +6 -0
- package/bin/session/storage/Persistence.d.ts.map +1 -1
- package/bin/session/storage/Persistence.js +7 -0
- package/bin/session/storage/Persistence.js.map +1 -1
- package/bin/session/storage/RuntimeSessionPort.d.ts +3 -3
- package/bin/session/storage/RuntimeSessionPort.d.ts.map +1 -1
- package/bin/session/storage/RuntimeSessionPort.js +2 -2
- package/bin/session/storage/RuntimeSessionPort.js.map +1 -1
- package/bin/types/agent/SessionActor.d.ts +15 -5
- package/bin/types/agent/SessionActor.d.ts.map +1 -1
- package/bin/types/rpc/RpcProtocol.d.ts +48 -61
- package/bin/types/rpc/RpcProtocol.d.ts.map +1 -1
- package/bin/types/runtime/agent/AgentContext.d.ts +5 -4
- package/bin/types/runtime/agent/AgentContext.d.ts.map +1 -1
- package/bin/types/runtime/agent/AgentContext.js.map +1 -1
- package/bin/types/session/SessionApproval.d.ts +97 -0
- package/bin/types/session/SessionApproval.d.ts.map +1 -0
- package/bin/types/session/SessionApproval.js +7 -0
- package/bin/types/session/SessionApproval.js.map +1 -0
- package/bin/types/session/SessionMessage.d.ts +223 -0
- package/bin/types/session/SessionMessage.d.ts.map +1 -0
- package/bin/types/session/SessionMessage.js +8 -0
- package/bin/types/session/SessionMessage.js.map +1 -0
- package/bin/types/session/SessionMutation.d.ts +116 -0
- package/bin/types/session/SessionMutation.d.ts.map +1 -0
- package/bin/types/session/SessionMutation.js +19 -0
- package/bin/types/session/SessionMutation.js.map +1 -0
- package/bin/types/session/SessionOptions.d.ts +9 -0
- package/bin/types/session/SessionOptions.d.ts.map +1 -1
- package/package.json +3 -3
- package/scripts/agent-env.test.mjs +4 -4
- package/scripts/remote-session-reconnect.test.mjs +7 -3
- package/scripts/session-action-message.test.mjs +98 -27
- package/scripts/session-history-inflight.test.mjs +2 -2
- package/scripts/session-list-title.test.mjs +6 -19
- package/scripts/session-prompt-runtime.test.mjs +7 -19
- package/scripts/session-recorder.test.mjs +199 -0
- package/scripts/session-title-event.test.mjs +10 -18
- package/src/agent/local/Agent.ts +1 -55
- package/src/agent/local/services/AgentAssemblyService.ts +39 -3
- package/src/agent/local/services/AgentSessions.ts +48 -3
- package/src/agent/remote/RemoteAgent.ts +1 -54
- package/src/agent/remote/RemoteSession.ts +106 -136
- package/src/agent/remote/RemoteTransport.ts +30 -30
- package/src/agent/remote/transports/HttpRemoteAgentTransport.ts +52 -77
- package/src/agent/remote/transports/RpcRemoteAgentTransport.ts +30 -34
- package/src/executor/store/history/jsonl/JsonlSessionHistoryStore.ts +2 -2
- package/src/executor/types/SessionHistoryPaths.ts +1 -1
- package/src/executor/types/SessionRecords.ts +2 -0
- package/src/index.ts +36 -10
- package/src/rpc/Client.ts +58 -95
- package/src/session/Session.ts +128 -37
- package/src/session/browse/Browse.ts +51 -22
- package/src/session/recorder/JsonlSessionMessageStore.ts +246 -0
- package/src/session/recorder/SessionMessageCodec.ts +284 -0
- package/src/session/recorder/SessionRecorder.ts +871 -0
- package/src/session/recorder/SessionRecorderCompaction.ts +188 -0
- package/src/session/recorder/SessionRecorderHistoryStore.ts +276 -0
- package/src/session/runtime/SessionEventHub.ts +25 -12
- package/src/session/runtime/SessionPromptRuntime.ts +60 -43
- package/src/session/services/SessionStateService.ts +159 -86
- package/src/session/services/SessionTurnService.ts +91 -73
- package/src/session/services/SessionViewService.ts +25 -13
- package/src/session/storage/Paths.ts +2 -2
- package/src/session/storage/Persistence.ts +13 -0
- package/src/session/storage/RuntimeSessionPort.ts +8 -9
- package/src/types/agent/SessionActor.ts +31 -8
- package/src/types/rpc/RpcProtocol.ts +44 -67
- package/src/types/runtime/agent/AgentContext.ts +9 -7
- package/src/types/session/SessionApproval.ts +108 -0
- package/src/types/session/SessionMessage.ts +253 -0
- package/src/types/session/SessionMutation.ts +141 -0
- package/src/types/session/SessionOptions.ts +25 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/bin/session/SessionEventMapper.d.ts +0 -32
- package/bin/session/SessionEventMapper.d.ts.map +0 -1
- package/bin/session/SessionEventMapper.js +0 -131
- package/bin/session/SessionEventMapper.js.map +0 -1
- package/bin/types/sdk/AgentSessionEvent.d.ts +0 -302
- package/bin/types/sdk/AgentSessionEvent.d.ts.map +0 -1
- package/bin/types/sdk/AgentSessionEvent.js +0 -9
- package/bin/types/sdk/AgentSessionEvent.js.map +0 -1
- package/bin/types/sdk/InternalUiChunkEvent.d.ts +0 -100
- package/bin/types/sdk/InternalUiChunkEvent.d.ts.map +0 -1
- package/bin/types/sdk/InternalUiChunkEvent.js +0 -9
- package/bin/types/sdk/InternalUiChunkEvent.js.map +0 -1
- package/scripts/session-history-archive.test.mjs +0 -188
- package/src/session/SessionEventMapper.ts +0 -148
- package/src/types/sdk/AgentSessionEvent.ts +0 -370
- 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 (
|
|
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.
|
|
147
|
-
[
|
|
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.
|
|
157
|
+
assert.equal(new Set(events.map((event) => event.message_id)).size, 1);
|
|
150
158
|
|
|
151
|
-
const
|
|
152
|
-
const action_events =
|
|
153
|
-
(item) => item.
|
|
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.
|
|
157
|
-
[
|
|
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
|
|
181
|
-
const action_events =
|
|
182
|
-
(item) => item.
|
|
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 (
|
|
263
|
-
|
|
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.
|
|
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.
|
|
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
|
|
277
|
-
const source_fork_actions =
|
|
278
|
-
(item) => item.
|
|
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.
|
|
352
|
+
source_fork_actions.map((event) => event.status),
|
|
282
353
|
["completed"],
|
|
283
354
|
);
|
|
284
355
|
|
|
285
|
-
const
|
|
286
|
-
const forked_model_actions =
|
|
287
|
-
(item) => item.
|
|
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 {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 关键点(中文)
|
|
5
5
|
* - 直接覆盖 JSONL history store 的落盘语义,确保运行中不中断丢失过程。
|
|
6
6
|
* - `messages.jsonl` 仍保持一轮一条 assistant 的正式历史语义。
|
|
7
|
-
* - 运行中快照放在 `
|
|
7
|
+
* - 运行中快照放在 `assistant_message.json`,浏览层读取时也应可见。
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import test from "node:test";
|
|
@@ -93,7 +93,7 @@ test("JsonlSessionHistoryStore exposes inflight assistant during execution and f
|
|
|
93
93
|
"sessions",
|
|
94
94
|
"session_test",
|
|
95
95
|
"messages",
|
|
96
|
-
"
|
|
96
|
+
"assistant_message.json",
|
|
97
97
|
);
|
|
98
98
|
const inflight_exists = await fs
|
|
99
99
|
.access(inflight_path)
|
|
@@ -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
|
|
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
|
|
166
|
-
|
|
167
|
-
|
|
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, "
|
|
174
|
+
assert.equal(page.items[0].previewText, "Recorder appended history");
|
|
188
175
|
assert.equal(repaired_meta.messageCount, 2);
|
|
189
|
-
assert.equal(repaired_meta.previewText, "
|
|
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
|
-
|
|
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();
|
|
@@ -147,7 +135,7 @@ test("SessionPromptRuntime merges queued prompts at the next step boundary", asy
|
|
|
147
135
|
assert.equal(secondTurn.result, result);
|
|
148
136
|
assert.deepEqual(
|
|
149
137
|
events.map((event) => event.type),
|
|
150
|
-
["
|
|
138
|
+
["start", "finish"],
|
|
151
139
|
);
|
|
152
140
|
});
|
|
153
141
|
|
|
@@ -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
|
-
|
|
154
|
+
appendErrorMessage: async () => {},
|
|
167
155
|
executeTurn: async (input) => {
|
|
168
156
|
const deferred = createDeferred();
|
|
169
157
|
finishQueue.push({
|
|
@@ -204,7 +192,7 @@ test("SessionPromptRuntime moves unmerged prompts into the next turn", async ()
|
|
|
204
192
|
assert.equal(secondResult.text, "done:second");
|
|
205
193
|
assert.deepEqual(
|
|
206
194
|
events.map((event) => event.type),
|
|
207
|
-
["
|
|
195
|
+
["start", "finish", "start", "finish"],
|
|
208
196
|
);
|
|
209
197
|
});
|
|
210
198
|
|
|
@@ -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
|
-
|
|
212
|
+
appendErrorMessage: async () => {},
|
|
225
213
|
executeTurn: async (input) => {
|
|
226
214
|
await new Promise((resolve) => {
|
|
227
215
|
input.abortSignal.addEventListener("abort", resolve, { once: true });
|
|
@@ -265,7 +253,7 @@ test("SessionPromptRuntime stops current turn and cancels unmerged queued prompt
|
|
|
265
253
|
);
|
|
266
254
|
assert.deepEqual(
|
|
267
255
|
events.map((event) => event.type),
|
|
268
|
-
["
|
|
256
|
+
["start", "start", "finish", "finish"],
|
|
269
257
|
);
|
|
270
258
|
});
|
|
271
259
|
|
|
@@ -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
|
-
|
|
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,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file 验证 SessionRecorder 的 Message 快照、Assistant 草稿与实时事件一致性。
|
|
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(path.join(os.tmpdir(), "downcity-session-recorder-"));
|
|
18
|
+
const file_path = path.join(root_path, "messages.jsonl");
|
|
19
|
+
const assistant_message_file_path = path.join(root_path, "assistant_message.json");
|
|
20
|
+
const events = [];
|
|
21
|
+
const recorder = new SessionRecorder({
|
|
22
|
+
session_id,
|
|
23
|
+
store: new JsonlSessionMessageStore({ session_id, file_path }),
|
|
24
|
+
publish: (mutation) => events.push(mutation),
|
|
25
|
+
});
|
|
26
|
+
await recorder.initialize();
|
|
27
|
+
return { recorder, events, file_path, assistant_message_file_path };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function read_jsonl(file_path) {
|
|
31
|
+
const raw = await fs.readFile(file_path, "utf8");
|
|
32
|
+
return raw.split("\n").filter(Boolean).map((line) => JSON.parse(line));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test("delta 只更新 Assistant 草稿,完成后才写入 messages.jsonl", async () => {
|
|
36
|
+
const { recorder, events, file_path, assistant_message_file_path } = await create_recorder();
|
|
37
|
+
await recorder.append_user_message({
|
|
38
|
+
turn_id: "turn-1",
|
|
39
|
+
input_type: "prompt",
|
|
40
|
+
parts: [{ part_id: "user-text-1", type: "text", text: "你好", state: "done" }],
|
|
41
|
+
});
|
|
42
|
+
const writer = await recorder.open_assistant_message({ turn_id: "turn-1", segment_index: 1 });
|
|
43
|
+
await writer.apply_chunk({ type: "text-start", id: "text-1" });
|
|
44
|
+
await writer.apply_chunk({ type: "text-delta", id: "text-1", delta: "你" });
|
|
45
|
+
await writer.apply_chunk({ type: "text-delta", id: "text-1", delta: "好" });
|
|
46
|
+
|
|
47
|
+
const history_during_stream = await read_jsonl(file_path);
|
|
48
|
+
const draft = JSON.parse(await fs.readFile(assistant_message_file_path, "utf8"));
|
|
49
|
+
assert.equal(history_during_stream.length, 1);
|
|
50
|
+
assert.equal(history_during_stream[0].type, "user");
|
|
51
|
+
assert.equal(draft.type, "assistant");
|
|
52
|
+
assert.equal(draft.parts[0].text, "你好");
|
|
53
|
+
assert.equal(draft.parts[0].sequence, 1);
|
|
54
|
+
|
|
55
|
+
await writer.apply_chunk({ type: "text-end", id: "text-1" });
|
|
56
|
+
await writer.complete();
|
|
57
|
+
|
|
58
|
+
const history = await read_jsonl(file_path);
|
|
59
|
+
assert.deepEqual(history.map((message) => message.type), ["user", "assistant"]);
|
|
60
|
+
assert.equal(history[1].parts[0].text, "你好");
|
|
61
|
+
assert.equal(await fs.stat(assistant_message_file_path).then(() => true).catch(() => false), false);
|
|
62
|
+
assert.deepEqual(events.map((event) => `${event.variant}:${event.type}`), [
|
|
63
|
+
"message:user",
|
|
64
|
+
"message:assistant",
|
|
65
|
+
"part:text",
|
|
66
|
+
"delta:text",
|
|
67
|
+
"delta:text",
|
|
68
|
+
"part:text",
|
|
69
|
+
"message:assistant",
|
|
70
|
+
]);
|
|
71
|
+
assert.equal(history.some((value) => "variant" in value), false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("Tool Part 在文本中间创建后,输入、审批和输出都保持原顺序", async () => {
|
|
75
|
+
const { recorder, events, file_path } = await create_recorder("tool-order-test");
|
|
76
|
+
const writer = await recorder.open_assistant_message({ turn_id: "turn-1", segment_index: 1 });
|
|
77
|
+
await writer.apply_chunk({ type: "text-start", id: "text-1" });
|
|
78
|
+
await writer.apply_chunk({ type: "text-delta", id: "text-1", delta: "before" });
|
|
79
|
+
await writer.apply_chunk({ type: "text-end", id: "text-1" });
|
|
80
|
+
await writer.apply_chunk({ type: "tool-input-start", toolCallId: "call-1", toolName: "search" });
|
|
81
|
+
await writer.apply_chunk({
|
|
82
|
+
type: "tool-input-available",
|
|
83
|
+
toolCallId: "call-1",
|
|
84
|
+
toolName: "search",
|
|
85
|
+
input: { query: "downcity" },
|
|
86
|
+
});
|
|
87
|
+
assert.equal(recorder.get_message(writer.message_id).parts[1].state, "ready");
|
|
88
|
+
await writer.apply_chunk({
|
|
89
|
+
type: "tool-approval-request",
|
|
90
|
+
toolCallId: "call-1",
|
|
91
|
+
approvalId: "approval-1",
|
|
92
|
+
});
|
|
93
|
+
assert.equal(recorder.get_message(writer.message_id).parts[1].state, "approval-required");
|
|
94
|
+
await writer.apply_chunk({ type: "text-start", id: "text-2" });
|
|
95
|
+
await writer.apply_chunk({ type: "text-delta", id: "text-2", delta: "after" });
|
|
96
|
+
await writer.apply_chunk({ type: "text-end", id: "text-2" });
|
|
97
|
+
await writer.apply_chunk({ type: "tool-output-available", toolCallId: "call-1", output: { count: 1 } });
|
|
98
|
+
await writer.complete();
|
|
99
|
+
|
|
100
|
+
const assistant = (await read_jsonl(file_path))[0];
|
|
101
|
+
assert.deepEqual(assistant.parts.map((part) => part.type), ["text", "tool", "text"]);
|
|
102
|
+
assert.deepEqual(assistant.parts.map((part) => part.sequence), [1, 2, 3]);
|
|
103
|
+
assert.equal(assistant.parts[1].state, "completed");
|
|
104
|
+
assert.deepEqual(assistant.parts[1].output, { count: 1 });
|
|
105
|
+
assert.deepEqual(
|
|
106
|
+
events
|
|
107
|
+
.filter((event) => event.variant === "part" && event.type === "tool")
|
|
108
|
+
.map((event) => [event.part.sequence, event.part.state]),
|
|
109
|
+
[[2, "input-streaming"], [2, "ready"], [2, "approval-required"], [2, "completed"]],
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("重启时将 Assistant 草稿收口为 stopped,并将运行中 Action 标记失败", async () => {
|
|
114
|
+
const { recorder, file_path, assistant_message_file_path } = await create_recorder("recovery-test");
|
|
115
|
+
await recorder.append_user_message({
|
|
116
|
+
turn_id: "turn-1",
|
|
117
|
+
input_type: "prompt",
|
|
118
|
+
parts: [{ part_id: "u1", type: "text", text: "one", state: "done" }],
|
|
119
|
+
});
|
|
120
|
+
const writer = await recorder.open_assistant_message({ turn_id: "turn-1", segment_index: 1 });
|
|
121
|
+
await writer.apply_chunk({ type: "text-start", id: "text-1" });
|
|
122
|
+
await writer.apply_chunk({ type: "text-delta", id: "text-1", delta: "partial" });
|
|
123
|
+
await recorder.open_action_message({ message_id: "running-action", action_type: "fork", title: "Forking" });
|
|
124
|
+
|
|
125
|
+
const recovered_events = [];
|
|
126
|
+
const recovered = new SessionRecorder({
|
|
127
|
+
session_id: "recovery-test",
|
|
128
|
+
store: new JsonlSessionMessageStore({ session_id: "recovery-test", file_path }),
|
|
129
|
+
publish: (mutation) => recovered_events.push(mutation),
|
|
130
|
+
});
|
|
131
|
+
await recovered.initialize();
|
|
132
|
+
|
|
133
|
+
const page = await recovered.list_messages({ limit: 10 });
|
|
134
|
+
assert.deepEqual(page.items.map((message) => message.type), ["user", "assistant", "action"]);
|
|
135
|
+
assert.equal(page.items[1].status, "stopped");
|
|
136
|
+
assert.equal(page.items[1].parts[0].text, "partial");
|
|
137
|
+
assert.equal(page.items[2].status, "failed");
|
|
138
|
+
assert.deepEqual(recovered_events.map((event) => `${event.variant}:${event.type}`), [
|
|
139
|
+
"message:assistant",
|
|
140
|
+
"message:action",
|
|
141
|
+
]);
|
|
142
|
+
assert.equal(await fs.stat(assistant_message_file_path).then(() => true).catch(() => false), false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("Action revision 追加完整快照,读取时只返回最新版本", async () => {
|
|
146
|
+
const { recorder, file_path } = await create_recorder("action-revision-test");
|
|
147
|
+
const action = await recorder.open_action_message({
|
|
148
|
+
message_id: "action-1",
|
|
149
|
+
action_type: "compact",
|
|
150
|
+
title: "Compacting",
|
|
151
|
+
});
|
|
152
|
+
await action.complete({ title: "Compacted" });
|
|
153
|
+
const history = await read_jsonl(file_path);
|
|
154
|
+
assert.equal(history.length, 2);
|
|
155
|
+
assert.deepEqual(history.map((message) => message.revision), [1, 2]);
|
|
156
|
+
const page = await recorder.list_messages();
|
|
157
|
+
assert.equal(page.items.length, 1);
|
|
158
|
+
assert.equal(page.items[0].title, "Compacted");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("compact 追加 internal summary 且不重写已有 Message 快照", async () => {
|
|
162
|
+
const { recorder, file_path } = await create_recorder("compact-test");
|
|
163
|
+
for (let index = 1; index <= 6; index += 1) {
|
|
164
|
+
await recorder.append_user_message({
|
|
165
|
+
turn_id: `turn-${String(index)}`,
|
|
166
|
+
input_type: "prompt",
|
|
167
|
+
parts: [{ part_id: `user-${String(index)}`, type: "text", text: `message ${String(index)}`, state: "done" }],
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const before = await fs.readFile(file_path, "utf8");
|
|
171
|
+
const history_store = new SessionRecorderHistoryStore({ session_id: "compact-test", recorder });
|
|
172
|
+
const model = new MockLanguageModelV3({
|
|
173
|
+
modelId: "compact-model",
|
|
174
|
+
doGenerate: async () => ({
|
|
175
|
+
content: [{ type: "text", text: "Compact summary" }],
|
|
176
|
+
finishReason: { unified: "stop", raw: "stop" },
|
|
177
|
+
usage: {
|
|
178
|
+
inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
|
|
179
|
+
outputTokens: { total: 0, text: 0, reasoning: 0 },
|
|
180
|
+
},
|
|
181
|
+
warnings: [],
|
|
182
|
+
}),
|
|
183
|
+
});
|
|
184
|
+
const result = await history_store.compact({
|
|
185
|
+
model,
|
|
186
|
+
system: [],
|
|
187
|
+
keepLastMessages: 2,
|
|
188
|
+
maxInputTokensApprox: 1,
|
|
189
|
+
archiveOnCompact: false,
|
|
190
|
+
compactRatio: 0.5,
|
|
191
|
+
});
|
|
192
|
+
assert.equal(result.compacted, true);
|
|
193
|
+
assert.equal((await fs.readFile(file_path, "utf8")).startsWith(before), true);
|
|
194
|
+
const all = await recorder.list_messages({ limit: 100, include_internal: true });
|
|
195
|
+
const summary = all.items.find((message) => message.type === "assistant" && message.kind === "summary");
|
|
196
|
+
assert.equal(summary.visibility, "internal");
|
|
197
|
+
assert.equal(summary.parts[0].sequence, 1);
|
|
198
|
+
assert.equal((await history_store.list_records()).length, 4);
|
|
199
|
+
});
|
|
@@ -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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|