@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.
- package/bin/agent/local/services/AgentSessions.d.ts.map +1 -1
- package/bin/agent/local/services/AgentSessions.js +1 -4
- package/bin/agent/local/services/AgentSessions.js.map +1 -1
- package/bin/agent/remote/RemoteSession.d.ts +9 -3
- package/bin/agent/remote/RemoteSession.d.ts.map +1 -1
- package/bin/agent/remote/RemoteSession.js +19 -4
- package/bin/agent/remote/RemoteSession.js.map +1 -1
- package/bin/agent/remote/RemoteTransport.d.ts +7 -3
- package/bin/agent/remote/RemoteTransport.d.ts.map +1 -1
- package/bin/agent/remote/transports/HttpRemoteAgentTransport.d.ts +5 -2
- package/bin/agent/remote/transports/HttpRemoteAgentTransport.d.ts.map +1 -1
- package/bin/agent/remote/transports/HttpRemoteAgentTransport.js +25 -11
- package/bin/agent/remote/transports/HttpRemoteAgentTransport.js.map +1 -1
- package/bin/agent/remote/transports/RpcRemoteAgentTransport.d.ts +5 -2
- package/bin/agent/remote/transports/RpcRemoteAgentTransport.d.ts.map +1 -1
- package/bin/agent/remote/transports/RpcRemoteAgentTransport.js +5 -2
- package/bin/agent/remote/transports/RpcRemoteAgentTransport.js.map +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 +5 -2
- 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 +11 -4
- package/bin/rpc/Client.d.ts.map +1 -1
- package/bin/rpc/Client.js +14 -3
- package/bin/rpc/Client.js.map +1 -1
- package/bin/session/Session.d.ts +13 -3
- package/bin/session/Session.d.ts.map +1 -1
- package/bin/session/Session.js +47 -18
- package/bin/session/Session.js.map +1 -1
- package/bin/session/recorder/JsonlSessionMessageStore.d.ts +45 -0
- package/bin/session/recorder/JsonlSessionMessageStore.d.ts.map +1 -0
- package/bin/session/recorder/JsonlSessionMessageStore.js +127 -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 +252 -0
- package/bin/session/recorder/SessionMessageCodec.js.map +1 -0
- package/bin/session/recorder/SessionMessageReducer.d.ts +12 -0
- package/bin/session/recorder/SessionMessageReducer.d.ts.map +1 -0
- package/bin/session/recorder/SessionMessageReducer.js +109 -0
- package/bin/session/recorder/SessionMessageReducer.js.map +1 -0
- package/bin/session/recorder/SessionRecorder.d.ts +177 -0
- package/bin/session/recorder/SessionRecorder.d.ts.map +1 -0
- package/bin/session/recorder/SessionRecorder.js +610 -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 +141 -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/SessionMessageEventHub.d.ts +15 -0
- package/bin/session/runtime/SessionMessageEventHub.d.ts.map +1 -0
- package/bin/session/runtime/SessionMessageEventHub.js +28 -0
- package/bin/session/runtime/SessionMessageEventHub.js.map +1 -0
- package/bin/session/runtime/SessionPromptRuntime.d.ts +7 -13
- package/bin/session/runtime/SessionPromptRuntime.d.ts.map +1 -1
- package/bin/session/runtime/SessionPromptRuntime.js +13 -13
- package/bin/session/runtime/SessionPromptRuntime.js.map +1 -1
- package/bin/session/services/SessionStateService.d.ts +7 -3
- package/bin/session/services/SessionStateService.d.ts.map +1 -1
- package/bin/session/services/SessionStateService.js +132 -72
- package/bin/session/services/SessionStateService.js.map +1 -1
- package/bin/session/services/SessionTurnService.d.ts +4 -0
- package/bin/session/services/SessionTurnService.d.ts.map +1 -1
- package/bin/session/services/SessionTurnService.js +82 -52
- 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/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 -2
- package/bin/session/storage/RuntimeSessionPort.d.ts.map +1 -1
- package/bin/session/storage/RuntimeSessionPort.js.map +1 -1
- package/bin/types/agent/SessionActor.d.ts +10 -4
- package/bin/types/agent/SessionActor.d.ts.map +1 -1
- package/bin/types/rpc/RpcProtocol.d.ts +20 -6
- package/bin/types/rpc/RpcProtocol.d.ts.map +1 -1
- package/bin/types/runtime/agent/AgentContext.d.ts +4 -3
- package/bin/types/runtime/agent/AgentContext.d.ts.map +1 -1
- package/bin/types/runtime/agent/AgentContext.js.map +1 -1
- package/bin/types/sdk/AgentSessionEvent.d.ts +2 -1
- package/bin/types/sdk/AgentSessionEvent.d.ts.map +1 -1
- package/bin/types/session/SessionMessage.d.ts +217 -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/SessionMessageMutation.d.ts +92 -0
- package/bin/types/session/SessionMessageMutation.d.ts.map +1 -0
- package/bin/types/session/SessionMessageMutation.js +21 -0
- package/bin/types/session/SessionMessageMutation.js.map +1 -0
- package/package.json +3 -3
- package/scripts/agent-env.test.mjs +4 -4
- package/scripts/session-action-message.test.mjs +98 -27
- package/scripts/session-list-title.test.mjs +6 -19
- package/scripts/session-prompt-runtime.test.mjs +4 -16
- package/scripts/session-recorder.test.mjs +245 -0
- package/scripts/session-title-event.test.mjs +10 -18
- package/src/agent/local/services/AgentSessions.ts +1 -3
- package/src/agent/remote/RemoteSession.ts +35 -7
- package/src/agent/remote/RemoteTransport.ts +17 -6
- package/src/agent/remote/transports/HttpRemoteAgentTransport.ts +45 -13
- package/src/agent/remote/transports/RpcRemoteAgentTransport.ts +19 -6
- package/src/executor/types/SessionRecords.ts +2 -0
- package/src/index.ts +23 -5
- package/src/rpc/Client.ts +29 -8
- package/src/session/Session.ts +65 -31
- package/src/session/recorder/JsonlSessionMessageStore.ts +162 -0
- package/src/session/recorder/SessionMessageCodec.ts +279 -0
- package/src/session/recorder/SessionMessageReducer.ts +155 -0
- package/src/session/recorder/SessionRecorder.ts +812 -0
- package/src/session/recorder/SessionRecorderCompaction.ts +187 -0
- package/src/session/recorder/SessionRecorderHistoryStore.ts +276 -0
- package/src/session/runtime/SessionMessageEventHub.ts +35 -0
- package/src/session/runtime/SessionPromptRuntime.ts +23 -24
- package/src/session/services/SessionStateService.ts +151 -80
- package/src/session/services/SessionTurnService.ts +88 -59
- package/src/session/services/SessionViewService.ts +25 -13
- package/src/session/storage/Persistence.ts +13 -0
- package/src/session/storage/RuntimeSessionPort.ts +6 -4
- package/src/types/agent/SessionActor.ts +26 -6
- package/src/types/rpc/RpcProtocol.ts +20 -6
- package/src/types/runtime/agent/AgentContext.ts +8 -2
- package/src/types/sdk/AgentSessionEvent.ts +2 -0
- package/src/types/session/SessionMessage.ts +247 -0
- package/src/types/session/SessionMessageMutation.ts +121 -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/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/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 {
|
|
@@ -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();
|
|
@@ -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({
|
|
@@ -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 });
|
|
@@ -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,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
|
-
|
|
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();
|
|
@@ -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.
|
|
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 } : {}),
|