@downcity/agent 1.1.150 → 1.1.152

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 (36) hide show
  1. package/bin/executor/core-engine/CoreEngineRunner.d.ts.map +1 -1
  2. package/bin/executor/core-engine/CoreEngineRunner.js +8 -6
  3. package/bin/executor/core-engine/CoreEngineRunner.js.map +1 -1
  4. package/bin/executor/types/SessionRun.d.ts +6 -2
  5. package/bin/executor/types/SessionRun.d.ts.map +1 -1
  6. package/bin/executor/types/SessionRun.js +1 -1
  7. package/bin/session/browse/Browse.d.ts.map +1 -1
  8. package/bin/session/browse/Browse.js +10 -20
  9. package/bin/session/browse/Browse.js.map +1 -1
  10. package/bin/session/runtime/SessionPromptRuntime.d.ts +1 -1
  11. package/bin/session/runtime/SessionPromptRuntime.d.ts.map +1 -1
  12. package/bin/session/runtime/SessionPromptRuntime.js +3 -1
  13. package/bin/session/runtime/SessionPromptRuntime.js.map +1 -1
  14. package/bin/session/services/SessionStateService.d.ts +1 -1
  15. package/bin/session/services/SessionStateService.d.ts.map +1 -1
  16. package/bin/session/services/SessionStateService.js.map +1 -1
  17. package/bin/session/services/SessionTurnService.d.ts.map +1 -1
  18. package/bin/session/services/SessionTurnService.js +6 -2
  19. package/bin/session/services/SessionTurnService.js.map +1 -1
  20. package/bin/session/storage/Persistence.d.ts +5 -1
  21. package/bin/session/storage/Persistence.d.ts.map +1 -1
  22. package/bin/session/storage/Persistence.js +3 -1
  23. package/bin/session/storage/Persistence.js.map +1 -1
  24. package/bin/types/agent/SessionTypes.d.ts +2 -2
  25. package/package.json +3 -3
  26. package/scripts/session-list-title.test.mjs +141 -0
  27. package/scripts/session-prompt-runtime.test.mjs +38 -0
  28. package/src/executor/core-engine/CoreEngineRunner.ts +16 -17
  29. package/src/executor/types/SessionRun.ts +6 -2
  30. package/src/session/browse/Browse.ts +18 -22
  31. package/src/session/runtime/SessionPromptRuntime.ts +4 -2
  32. package/src/session/services/SessionStateService.ts +1 -1
  33. package/src/session/services/SessionTurnService.ts +7 -3
  34. package/src/session/storage/Persistence.ts +8 -2
  35. package/src/types/agent/SessionTypes.ts +2 -2
  36. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @file 验证 session 列表从正确目录读取持久化 title。
3
+ *
4
+ * 关键点(中文)
5
+ * - 普通列表只读取 `.downcity/agents/<agentId>/sessions` 下的 meta。
6
+ * - 归档列表只读取 `.downcity/agents/<agentId>/archived-sessions` 下的 meta。
7
+ * - title 允许为空;这里仅验证已由模型生成并落盘的 title 能被列表返回。
8
+ */
9
+
10
+ import test from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import fs from "node:fs/promises";
15
+
16
+ import { MockLanguageModelV3 } from "ai/test";
17
+ import { Agent } from "../bin/index.js";
18
+
19
+ function create_mock_title_model(title_text) {
20
+ return new MockLanguageModelV3({
21
+ modelId: "mock-session-list-title-model",
22
+ doGenerate: async () => ({
23
+ content: [
24
+ {
25
+ type: "text",
26
+ text: title_text,
27
+ },
28
+ ],
29
+ finishReason: "stop",
30
+ usage: {
31
+ inputTokens: {
32
+ total: 0,
33
+ noCache: 0,
34
+ cacheRead: 0,
35
+ cacheWrite: 0,
36
+ },
37
+ outputTokens: {
38
+ total: 0,
39
+ text: 0,
40
+ reasoning: 0,
41
+ },
42
+ },
43
+ warnings: [],
44
+ }),
45
+ });
46
+ }
47
+
48
+ async function create_agent_with_titled_session(input) {
49
+ const agent_path = await fs.mkdtemp(
50
+ path.join(os.tmpdir(), input.tmp_prefix),
51
+ );
52
+ const agent = new Agent({
53
+ id: input.agent_id,
54
+ path: agent_path,
55
+ model: create_mock_title_model(input.title),
56
+ });
57
+ const collection = agent.session_collection();
58
+ const session = await collection.create_session({
59
+ sessionId: input.session_id,
60
+ });
61
+
62
+ await session.appendUserMessage({
63
+ text: input.first_user_text,
64
+ });
65
+
66
+ return {
67
+ agent,
68
+ collection,
69
+ session,
70
+ };
71
+ }
72
+
73
+ test("list_sessions returns persisted title from active session metadata", async () => {
74
+ const { agent, collection, session } = await create_agent_with_titled_session({
75
+ tmp_prefix: "downcity-agent-session-list-title-",
76
+ agent_id: "list_title_agent",
77
+ session_id: "active_session",
78
+ title: "列表标题",
79
+ first_user_text: "Need the session list to show the generated title",
80
+ });
81
+
82
+ try {
83
+ const page = await collection.list_sessions();
84
+
85
+ assert.equal(page.total, 1);
86
+ assert.deepEqual(
87
+ page.items.map((item) => ({
88
+ sessionId: item.sessionId,
89
+ title: item.title,
90
+ messageCount: item.messageCount,
91
+ })),
92
+ [
93
+ {
94
+ sessionId: session.id,
95
+ title: "列表标题",
96
+ messageCount: 1,
97
+ },
98
+ ],
99
+ );
100
+ } finally {
101
+ await agent.dispose();
102
+ }
103
+ });
104
+
105
+ test("archive_sessions returns title from archived session metadata", async () => {
106
+ const { agent, collection, session } = await create_agent_with_titled_session({
107
+ tmp_prefix: "downcity-agent-archive-list-title-",
108
+ agent_id: "archive_list_title_agent",
109
+ session_id: "archived_session",
110
+ title: "归档标题",
111
+ first_user_text: "Archive this titled session",
112
+ });
113
+
114
+ try {
115
+ await collection.archive_session({
116
+ id: session.id,
117
+ });
118
+
119
+ const active_page = await collection.list_sessions();
120
+ const archived_page = await collection.archive_sessions();
121
+
122
+ assert.equal(active_page.total, 0);
123
+ assert.equal(archived_page.total, 1);
124
+ assert.deepEqual(
125
+ archived_page.items.map((item) => ({
126
+ sessionId: item.sessionId,
127
+ title: item.title,
128
+ messageCount: item.messageCount,
129
+ })),
130
+ [
131
+ {
132
+ sessionId: session.id,
133
+ title: "归档标题",
134
+ messageCount: 1,
135
+ },
136
+ ],
137
+ );
138
+ } finally {
139
+ await agent.dispose();
140
+ }
141
+ });
@@ -247,3 +247,41 @@ test("SessionPromptRuntime stops current turn and cancels unmerged queued prompt
247
247
  ["turn-start", "turn-start", "turn-finish", "turn-finish"],
248
248
  );
249
249
  });
250
+
251
+ test("SessionPromptRuntime does not synthesize assistant text when stopped before output", async () => {
252
+ const executionFinished = createDeferred();
253
+
254
+ const runtime = new SessionPromptRuntime({
255
+ sessionId: "test",
256
+ publish: () => {},
257
+ createAndPersistUserMessage: async (input) => {
258
+ return createUserMessage(input.query, 1);
259
+ },
260
+ executeTurn: async (input) => {
261
+ await new Promise((resolve) => {
262
+ input.abortSignal.addEventListener("abort", resolve, { once: true });
263
+ });
264
+ await executionFinished.promise;
265
+ return {
266
+ text: "",
267
+ success: false,
268
+ error: "Turn stopped",
269
+ };
270
+ },
271
+ stopTurn: () => {
272
+ executionFinished.resolve();
273
+ return true;
274
+ },
275
+ });
276
+
277
+ const turn = await runtime.prompt({ query: "first" });
278
+ await waitUntil(() => runtime.isActive());
279
+
280
+ runtime.stop();
281
+ const result = await turn.finished;
282
+
283
+ assert.equal(result.success, false);
284
+ assert.equal(result.error, "Turn stopped");
285
+ assert.equal(result.text, "");
286
+ assert.equal(result.assistantMessage, undefined);
287
+ });
@@ -227,6 +227,15 @@ export class CoreEngineRunner {
227
227
  abortSignal: input.run_context.abortSignal,
228
228
  });
229
229
 
230
+ final_assistant_ui_message = mergeAssistantUiMessages(
231
+ final_assistant_ui_message,
232
+ step_assistant_ui_message,
233
+ );
234
+
235
+ // 关键点(中文):先保存本 step 已收敛的 assistant 消息,再等待 steps。
236
+ // stop/abort 时 `result.steps` 可能抛错,但当前已经生成的文本仍应沉淀。
237
+ message_state.appendRuntimeSessionMessage(step_assistant_ui_message);
238
+
230
239
  const executed_steps = await result.steps;
231
240
  const last_step = executed_steps[executed_steps.length - 1];
232
241
  if (!last_step) break;
@@ -313,14 +322,6 @@ export class CoreEngineRunner {
313
322
  : [];
314
323
  message_state.appendModelMessages(response_messages);
315
324
 
316
- final_assistant_ui_message = mergeAssistantUiMessages(
317
- final_assistant_ui_message,
318
- step_assistant_ui_message,
319
- );
320
-
321
- // 关键点(中文):把本 step 的 assistant UI 消息并入运行时上下文,保证后续全量重算不丢历史。
322
- message_state.appendRuntimeSessionMessage(step_assistant_ui_message);
323
-
324
325
  if (loop_decision.continueForToolCalls) {
325
326
  text_only_continuation_count = 0;
326
327
  incomplete_response_recovery_count = 0;
@@ -414,18 +415,16 @@ export class CoreEngineRunner {
414
415
  await this.logger.log("info", "[agent] stopped", {
415
416
  sessionId: session_id,
416
417
  });
417
- const stopped_message = mergePendingAssistantFileParts(
418
- final_assistant_ui_message ||
419
- this.context_composer.buildFallbackAssistantMessage(
420
- error_text,
421
- input.run_context,
422
- ),
423
- input.run_context.pendingAssistantFileParts,
424
- );
418
+ const stopped_message = final_assistant_ui_message
419
+ ? mergePendingAssistantFileParts(
420
+ final_assistant_ui_message,
421
+ input.run_context.pendingAssistantFileParts,
422
+ )
423
+ : null;
425
424
  return {
426
425
  success: false,
427
426
  error: error_text,
428
- assistantMessage: stopped_message,
427
+ ...(stopped_message ? { assistantMessage: stopped_message } : {}),
429
428
  deferredPersistedUserMessages: [
430
429
  ...input.run_context.deferredPersistedUserMessages,
431
430
  ],
@@ -4,7 +4,7 @@
4
4
  * 关键点(中文)
5
5
  * - `SessionRunInput` 表示上层会话入口输入(例如 context query)。
6
6
  * - `SessionExecuteInput` 表示 Executor 通过 Composer 装配后的中间运行态。
7
- * - 输出仅暴露 assistantMessage(UIMessage)。
7
+ * - 输出暴露可选 assistantMessage(UIMessage)。
8
8
  */
9
9
 
10
10
  import type { Tool, UIMessageChunk } from "ai";
@@ -94,8 +94,12 @@ export interface SessionRunResult {
94
94
 
95
95
  /**
96
96
  * 最终 assistant 消息。
97
+ *
98
+ * 关键点(中文)
99
+ * - stop/abort 且没有任何 assistant 内容时可以为空。
100
+ * - turn 状态通过 `success` / `error` 表达,不应伪造成 assistant 正文。
97
101
  */
98
- assistantMessage: SessionMessageV1;
102
+ assistantMessage?: SessionMessageV1 | null;
99
103
 
100
104
  /**
101
105
  * 本轮执行结束后待写入长期历史的 user 消息。
@@ -32,15 +32,12 @@ import type {
32
32
  import type { SessionHistoryMetaV1 } from "@/executor/types/SessionHistoryMeta.js";
33
33
  import { pickLastSuccessfulChatSendText } from "@/executor/messages/UserVisibleText.js";
34
34
  import { getSdkAgentSessionMessagesPath } from "@/session/storage/Paths.js";
35
+ import { getSdkAgentSessionMetaPath } from "@/session/storage/Paths.js";
35
36
  import { getSdkAgentSessionsRootDirPath } from "@/session/storage/Paths.js";
36
37
  import { getSdkAgentArchivedSessionsDirPath } from "@/session/storage/Paths.js";
37
38
  import { getSdkAgentArchivedSessionMessagesPath } from "@/session/storage/Paths.js";
38
39
  import { getSdkAgentArchivedSessionMetaPath } from "@/session/storage/Paths.js";
39
- import { readSessionMetadata } from "@/session/storage/Metadata.js";
40
40
  import { readSessionMetadataFromPath } from "@/session/storage/Metadata.js";
41
- import {
42
- ensureSessionTitle,
43
- } from "@/session/SessionTitle.js";
44
41
 
45
42
  type AnyUiPart = UIMessagePart<Record<string, never>, Record<string, never>>;
46
43
 
@@ -479,7 +476,7 @@ export async function listAgentSessionSummaryPage(params: {
479
476
  const sessionId = decodeMaybe(entry.name);
480
477
  if (!sessionId) continue;
481
478
  const metadata = await readSessionMetadataFromPath({
482
- filePath: getSdkAgentArchivedSessionMetaPath(
479
+ filePath: getSdkAgentSessionMetaPath(
483
480
  params.projectRoot,
484
481
  params.agentId,
485
482
  sessionId,
@@ -488,19 +485,17 @@ export async function listAgentSessionSummaryPage(params: {
488
485
  agentId: params.agentId,
489
486
  });
490
487
  const messages = await loadSessionMessagesFromPath(
491
- getSdkAgentArchivedSessionMessagesPath(
488
+ getSdkAgentSessionMessagesPath(
492
489
  params.projectRoot,
493
490
  params.agentId,
494
491
  sessionId,
495
492
  ),
496
493
  );
497
- // 关键点(中文):归档 session 不再生成新 title,仅读取已有 meta。
498
- const metadataWithTitle = metadata;
499
494
  const info = buildSessionInfo({
500
495
  projectRoot: params.projectRoot,
501
496
  agentId: params.agentId,
502
497
  sessionId,
503
- metadata: metadataWithTitle,
498
+ metadata,
504
499
  messages,
505
500
  executing: params.executingSessionIds?.has(sessionId),
506
501
  });
@@ -575,27 +570,28 @@ export async function listArchivedAgentSessionSummaryPage(params: {
575
570
  if (!entry.isDirectory()) continue;
576
571
  const sessionId = decodeMaybe(entry.name);
577
572
  if (!sessionId) continue;
578
- const metadata = await readSessionMetadata({
579
- projectRoot: params.projectRoot,
580
- agentId: params.agentId,
573
+ const metadata = await readSessionMetadataFromPath({
574
+ filePath: getSdkAgentArchivedSessionMetaPath(
575
+ params.projectRoot,
576
+ params.agentId,
577
+ sessionId,
578
+ ),
581
579
  sessionId,
580
+ agentId: params.agentId,
582
581
  });
583
582
  const messages = await loadSessionMessagesFromPath(
584
- getSdkAgentSessionMessagesPath(params.projectRoot, params.agentId, sessionId),
583
+ getSdkAgentArchivedSessionMessagesPath(
584
+ params.projectRoot,
585
+ params.agentId,
586
+ sessionId,
587
+ ),
585
588
  );
586
- const metadataWithTitle = metadata.title
587
- ? metadata
588
- : await ensureSessionTitle({
589
- projectRoot: params.projectRoot,
590
- agentId: params.agentId,
591
- sessionId,
592
- messages,
593
- });
589
+ // 关键点(中文):归档 session 不再生成新 title,仅读取归档目录内已有 meta。
594
590
  const info = buildSessionInfo({
595
591
  projectRoot: params.projectRoot,
596
592
  agentId: params.agentId,
597
593
  sessionId,
598
- metadata: metadataWithTitle,
594
+ metadata,
599
595
  messages,
600
596
  executing: false,
601
597
  });
@@ -106,7 +106,7 @@ export interface SessionPromptRuntimeOptions {
106
106
  }) => Promise<{
107
107
  text: string;
108
108
  success: boolean;
109
- assistantMessage: SessionMessageV1;
109
+ assistantMessage?: SessionMessageV1 | null;
110
110
  error?: string;
111
111
  }>;
112
112
 
@@ -233,7 +233,9 @@ export class SessionPromptRuntime {
233
233
  turnId,
234
234
  text: result.text,
235
235
  success: stopped ? false : result.success,
236
- assistantMessage: result.assistantMessage,
236
+ ...(result.assistantMessage
237
+ ? { assistantMessage: result.assistantMessage }
238
+ : {}),
237
239
  ...(stopped
238
240
  ? { error: TURN_STOPPED_MESSAGE }
239
241
  : result.error ? { error: result.error } : {}),
@@ -289,7 +289,7 @@ export class SessionStateService {
289
289
  * 持久化最终 assistant 结果。
290
290
  */
291
291
  async persist_assistant_result(
292
- assistant_message: SessionMessageV1,
292
+ assistant_message?: SessionMessageV1 | null,
293
293
  ): Promise<void> {
294
294
  await persistSdkAssistantResult({
295
295
  projectRoot: this.project_root,
@@ -146,7 +146,7 @@ export class SessionTurnService {
146
146
  }): Promise<{
147
147
  text: string;
148
148
  success: boolean;
149
- assistantMessage: SessionMessageV1;
149
+ assistantMessage?: SessionMessageV1 | null;
150
150
  error?: string;
151
151
  }> {
152
152
  const tool_name_by_call_id = new Map<string, string>();
@@ -214,9 +214,13 @@ export class SessionTurnService {
214
214
  result.deferredPersistedUserMessages,
215
215
  );
216
216
  return {
217
- text: extractTextFromUiMessage(result.assistantMessage),
217
+ text: result.assistantMessage
218
+ ? extractTextFromUiMessage(result.assistantMessage)
219
+ : "",
218
220
  success: result.success,
219
- assistantMessage: result.assistantMessage,
221
+ ...(result.assistantMessage
222
+ ? { assistantMessage: result.assistantMessage }
223
+ : {}),
220
224
  ...(result.error ? { error: result.error } : {}),
221
225
  };
222
226
  }
@@ -60,8 +60,12 @@ export interface PersistSdkAssistantResultParams
60
60
  };
61
61
  /**
62
62
  * 本轮执行得到的 assistant 消息。
63
+ *
64
+ * 关键点(中文)
65
+ * - stop/abort 且没有 assistant 内容时允许为空。
66
+ * - 为空时仅刷新 metadata,不写入伪造 assistant 正文。
63
67
  */
64
- assistantMessage: SessionMessageV1;
68
+ assistantMessage?: SessionMessageV1 | null;
65
69
  }
66
70
 
67
71
  /**
@@ -104,7 +108,9 @@ export async function persistSdkAssistantResult(
104
108
  await persistAssistantResult({
105
109
  writer: params.executor,
106
110
  assistantMessage: params.assistantMessage,
107
- fallbackText: extractTextFromUiMessage(params.assistantMessage),
111
+ fallbackText: params.assistantMessage
112
+ ? extractTextFromUiMessage(params.assistantMessage)
113
+ : undefined,
108
114
  });
109
115
  await touchSessionMetadata(params);
110
116
  }
@@ -212,8 +212,8 @@ export interface AgentSessionSummary {
212
212
  *
213
213
  * 说明(中文)
214
214
  * - 标题持久化在 session `meta.json` 顶层。
215
- * - 首条用户消息出现后,SDK 会优先生成标题,失败时回退到首条用户消息截断。
216
- * - session 可能暂时没有标题,调用方可回退到 `sessionId`。
215
+ * - SDK 只在模型成功生成标题时写入,不再从首条用户消息生成 fallback。
216
+ * - 标题允许为空,调用方需要展示占位文案时可自行回退到 `sessionId`。
217
217
  */
218
218
  title?: string;
219
219
  /**