@downcity/agent 1.1.62 → 1.1.64

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.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @file 验证 Session 标题会进入 subscribe 事件与 history session 信息。
3
+ *
4
+ * 关键点(中文)
5
+ * - 这里走编译后的公开 SDK,锁住调用方实际可见行为。
6
+ * - 使用 appendUserMessage 触发 fallback 标题,避免测试依赖真实模型。
7
+ */
8
+
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import fs from "node:fs/promises";
14
+
15
+ import { Agent } from "../bin/index.js";
16
+
17
+ test("Session publishes title event and exposes title in history", async () => {
18
+ const agent_path = await fs.mkdtemp(
19
+ path.join(os.tmpdir(), "downcity-agent-session-title-"),
20
+ );
21
+ const agent = new Agent({
22
+ id: "title_agent",
23
+ path: agent_path,
24
+ });
25
+ const session = await agent.createSession();
26
+ const events = [];
27
+ const unsubscribe = session.subscribe((event) => {
28
+ events.push(event);
29
+ });
30
+
31
+ try {
32
+ await session.appendUserMessage({
33
+ text: "Use shell tools to inspect the current workspace",
34
+ });
35
+
36
+ const title_event = events.find((event) => event.type === "session-title");
37
+ assert.deepEqual(title_event, {
38
+ type: "session-title",
39
+ sessionId: session.id,
40
+ title: "Use shell tools to inspect the current workspace",
41
+ });
42
+
43
+ const history = await session.history();
44
+ assert.equal(
45
+ history.session.title,
46
+ "Use shell tools to inspect the current workspace",
47
+ );
48
+ } finally {
49
+ unsubscribe();
50
+ }
51
+ });
@@ -34,6 +34,24 @@ type ProviderPromptMessage = {
34
34
 
35
35
  type ProviderStreamController = ReadableStreamDefaultController<Record<string, unknown>>;
36
36
  type ProviderContentPart = Record<string, unknown>;
37
+ type ProviderPromptRole = "system" | "user" | "assistant" | "tool";
38
+
39
+ type ProviderToolResultOutput = {
40
+ /**
41
+ * tool result 输出类型。
42
+ */
43
+ type?: unknown;
44
+
45
+ /**
46
+ * tool result 输出值。
47
+ */
48
+ value?: unknown;
49
+
50
+ /**
51
+ * tool 拒绝原因。
52
+ */
53
+ reason?: unknown;
54
+ };
37
55
 
38
56
  function normalizeFinishReason(input: unknown): {
39
57
  unified: "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other";
@@ -72,12 +90,72 @@ function fileUrlFromProviderPart(part: Record<string, unknown>): string {
72
90
  return "";
73
91
  }
74
92
 
75
- function providerContentToUiParts(content: unknown): UIMessage["parts"] {
93
+ function resolveProviderPromptRole(input: unknown): ProviderPromptRole {
94
+ return input === "system" || input === "user" || input === "assistant" || input === "tool"
95
+ ? input
96
+ : "user";
97
+ }
98
+
99
+ function normalizeProviderToolResultOutput(
100
+ output: ProviderToolResultOutput,
101
+ ): {
102
+ /**
103
+ * 归一后的 tool part 状态。
104
+ */
105
+ state: "output-available" | "output-error";
106
+
107
+ /**
108
+ * tool 成功输出。
109
+ */
110
+ output?: unknown;
111
+
112
+ /**
113
+ * tool 错误文本。
114
+ */
115
+ errorText?: string;
116
+ } {
117
+ const outputType = typeof output.type === "string" ? output.type : "";
118
+ if (outputType === "json" || outputType === "text" || outputType === "content") {
119
+ return {
120
+ state: "output-available",
121
+ output: "value" in output ? output.value : null,
122
+ };
123
+ }
124
+ if (outputType === "error-json") {
125
+ return {
126
+ state: "output-error",
127
+ errorText: stringifyToolInput("value" in output ? output.value : null),
128
+ };
129
+ }
130
+ if (outputType === "error-text") {
131
+ return {
132
+ state: "output-error",
133
+ errorText: String(output.value ?? ""),
134
+ };
135
+ }
136
+ if (outputType === "execution-denied") {
137
+ return {
138
+ state: "output-error",
139
+ errorText: String(output.reason ?? "tool execution denied"),
140
+ };
141
+ }
142
+ return {
143
+ state: "output-available",
144
+ output,
145
+ };
146
+ }
147
+
148
+ function providerContentToUiParts(
149
+ content: unknown,
150
+ existingParts?: UIMessage["parts"],
151
+ ): UIMessage["parts"] {
76
152
  if (!Array.isArray(content)) {
77
153
  return [{ type: "text", text: textFromProviderContent(content) }];
78
154
  }
79
155
 
80
- const parts: UIMessage["parts"] = [];
156
+ const parts: UIMessage["parts"] = Array.isArray(existingParts)
157
+ ? [...existingParts]
158
+ : [];
81
159
  for (const part of content) {
82
160
  if (!part || typeof part !== "object") continue;
83
161
  const record = part as Record<string, unknown>;
@@ -109,6 +187,55 @@ function providerContentToUiParts(content: unknown): UIMessage["parts"] {
109
187
  input: record.input,
110
188
  providerExecuted: Boolean(record.providerExecuted),
111
189
  });
190
+ continue;
191
+ }
192
+ if (record.type === "tool-result") {
193
+ const toolCallId = String(record.toolCallId ?? "");
194
+ const toolName = String(record.toolName ?? "");
195
+ const normalizedOutput = normalizeProviderToolResultOutput(
196
+ (record.output as ProviderToolResultOutput | undefined) ?? {},
197
+ );
198
+ const existingPart = parts.find((item) => {
199
+ if (!item || typeof item !== "object") return false;
200
+ const toolPart = item as { toolCallId?: unknown };
201
+ return String(toolPart.toolCallId ?? "") === toolCallId;
202
+ }) as
203
+ | ({
204
+ toolCallId?: unknown;
205
+ toolName?: unknown;
206
+ input?: unknown;
207
+ providerExecuted?: unknown;
208
+ } & Record<string, unknown>)
209
+ | undefined;
210
+ const baseInput = existingPart?.input ?? null;
211
+ const baseToolName = String(existingPart?.toolName ?? toolName);
212
+ const nextPart = normalizedOutput.state === "output-available"
213
+ ? {
214
+ type: "dynamic-tool" as const,
215
+ toolName: baseToolName,
216
+ toolCallId,
217
+ state: "output-available" as const,
218
+ input: baseInput,
219
+ output: normalizedOutput.output,
220
+ providerExecuted: false,
221
+ }
222
+ : {
223
+ type: "dynamic-tool" as const,
224
+ toolName: baseToolName,
225
+ toolCallId,
226
+ state: "output-error" as const,
227
+ input: baseInput,
228
+ errorText: normalizedOutput.errorText ?? "tool_error",
229
+ providerExecuted: false,
230
+ };
231
+ if (existingPart) {
232
+ const index = parts.indexOf(existingPart as never);
233
+ if (index >= 0) {
234
+ parts[index] = nextPart;
235
+ continue;
236
+ }
237
+ }
238
+ parts.push(nextPart);
112
239
  }
113
240
  }
114
241
  return parts;
@@ -116,19 +243,36 @@ function providerContentToUiParts(content: unknown): UIMessage["parts"] {
116
243
 
117
244
  function providerPromptToMessages(prompt: unknown): UIMessage[] {
118
245
  if (!Array.isArray(prompt)) return [];
119
- return prompt
120
- .map((message, index): UIMessage | null => {
121
- if (!message || typeof message !== "object") return null;
122
- const item = message as ProviderPromptMessage;
123
- const role = item.role === "system" || item.role === "assistant" ? item.role : "user";
124
- const parts = providerContentToUiParts(item.content);
125
- return {
246
+ const messages: UIMessage[] = [];
247
+ for (const [index, message] of prompt.entries()) {
248
+ if (!message || typeof message !== "object") continue;
249
+ const item = message as ProviderPromptMessage;
250
+ const role = resolveProviderPromptRole(item.role);
251
+ if (role === "tool") {
252
+ const lastAssistantMessage = [...messages]
253
+ .reverse()
254
+ .find((candidate) => candidate.role === "assistant");
255
+ if (lastAssistantMessage) {
256
+ lastAssistantMessage.parts = providerContentToUiParts(
257
+ item.content,
258
+ lastAssistantMessage.parts,
259
+ );
260
+ continue;
261
+ }
262
+ messages.push({
126
263
  id: `city-model-message-${String(index)}`,
127
- role,
128
- parts,
129
- };
130
- })
131
- .filter((message): message is UIMessage => Boolean(message));
264
+ role: "assistant",
265
+ parts: providerContentToUiParts(item.content),
266
+ });
267
+ continue;
268
+ }
269
+ messages.push({
270
+ id: `city-model-message-${String(index)}`,
271
+ role,
272
+ parts: providerContentToUiParts(item.content),
273
+ });
274
+ }
275
+ return messages;
132
276
  }
133
277
 
134
278
  function providerOptionsToInput(options: Record<string, unknown>): CityModelInvokeInput {
@@ -161,15 +305,40 @@ function uiMessageToProviderContent(message: UIMessage): ProviderContentPart[] {
161
305
  toolCallId?: unknown;
162
306
  toolName?: unknown;
163
307
  input?: unknown;
308
+ output?: unknown;
309
+ errorText?: unknown;
310
+ state?: unknown;
164
311
  providerExecuted?: unknown;
165
312
  };
166
- return [{
313
+ const content: ProviderContentPart[] = [{
167
314
  type: "tool-call",
168
315
  toolCallId: String(toolPart.toolCallId ?? ""),
169
316
  toolName: String(toolPart.toolName ?? ""),
170
317
  input: stringifyToolInput(toolPart.input),
171
318
  providerExecuted: Boolean(toolPart.providerExecuted),
172
319
  }];
320
+ if (toolPart.state === "output-available") {
321
+ content.push({
322
+ type: "tool-result",
323
+ toolCallId: String(toolPart.toolCallId ?? ""),
324
+ toolName: String(toolPart.toolName ?? ""),
325
+ output: {
326
+ type: "json",
327
+ value: toolPart.output ?? null,
328
+ },
329
+ });
330
+ } else if (toolPart.state === "output-error") {
331
+ content.push({
332
+ type: "tool-result",
333
+ toolCallId: String(toolPart.toolCallId ?? ""),
334
+ toolName: String(toolPart.toolName ?? ""),
335
+ output: {
336
+ type: "error-text",
337
+ value: String(toolPart.errorText ?? "tool_error"),
338
+ },
339
+ });
340
+ }
341
+ return content;
173
342
  }
174
343
  return [];
175
344
  });
@@ -581,7 +581,13 @@ export class Session implements AgentSession {
581
581
  generate?: boolean;
582
582
  }): Promise<void> {
583
583
  const messages = await this.historyStore.list();
584
- await ensureSessionTitle({
584
+ const beforeMetadata = await readSessionMetadata({
585
+ projectRoot: this.projectRoot,
586
+ agentId: this.agentId,
587
+ sessionId: this.id,
588
+ });
589
+ const beforeTitle = String(beforeMetadata.title || "").trim();
590
+ const nextMetadata = await ensureSessionTitle({
585
591
  projectRoot: this.projectRoot,
586
592
  agentId: this.agentId,
587
593
  sessionId: this.id,
@@ -589,6 +595,13 @@ export class Session implements AgentSession {
589
595
  ...(input?.generate ? { model: this.sessionConfig.model } : {}),
590
596
  generate: input?.generate === true,
591
597
  });
598
+ const nextTitle = String(nextMetadata.title || "").trim();
599
+ if (!nextTitle || nextTitle === beforeTitle) return;
600
+ this.eventHub.publish({
601
+ type: "session-title",
602
+ sessionId: this.id,
603
+ title: nextTitle,
604
+ });
592
605
  }
593
606
 
594
607
  private async persistAssistantResult(
@@ -158,6 +158,30 @@ export interface AgentSessionAssistantStepEvent {
158
158
  visibility?: SessionAssistantStepVisibility;
159
159
  }
160
160
 
161
+ /**
162
+ * Session 标题更新事件。
163
+ */
164
+ export interface AgentSessionTitleEvent {
165
+ /**
166
+ * 当前事件类型。
167
+ */
168
+ type: "session-title";
169
+
170
+ /**
171
+ * 当前 session 唯一标识。
172
+ */
173
+ sessionId: string;
174
+
175
+ /**
176
+ * 当前 session 最新标题。
177
+ *
178
+ * 说明(中文)
179
+ * - 标题已持久化到 session meta。
180
+ * - 新 session 通常在首条 user message 落盘后生成标题。
181
+ */
182
+ title: string;
183
+ }
184
+
161
185
  /**
162
186
  * 单个 turn 完成事件。
163
187
  */
@@ -213,6 +237,7 @@ export type AgentSessionEvent =
213
237
  | AgentSessionToolCallEvent
214
238
  | AgentSessionToolResultEvent
215
239
  | AgentSessionAssistantStepEvent
240
+ | AgentSessionTitleEvent
216
241
  | AgentSessionTurnFinishEvent
217
242
  | AgentSessionErrorEvent;
218
243