From 726873a9dc63c39942de2837243beabe6105bffa Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 21 May 2026 23:52:10 +0800 Subject: [PATCH 1/6] Add automatic thread naming source flow --- .../020_chat_channel_name_source.sql | 10 + apps/node_backend/src/routes/chat.test.ts | 148 +++++++++- apps/node_backend/src/routes/chat.ts | 196 ++++++++++++- .../src/services/chatChannelNameService.ts | 97 ++++++- docs/code_maps/feature_map.yaml | 3 +- docs/code_maps/logic_map.yaml | 4 +- ...-16-43-CST-agent-loop-limit-empty-final.md | 4 +- ...05-21-19-57-CST-thread-auto-name-source.md | 42 +++ .../thread_auto_name_source/AGENTS.md | 109 +++++++ tools/evidence/thread_auto_name_source/run.sh | 108 +++++++ .../thread_auto_name_flow.mjs | 274 ++++++++++++++++++ 11 files changed, 982 insertions(+), 13 deletions(-) create mode 100644 apps/node_backend/src/db/migrations/020_chat_channel_name_source.sql create mode 100644 docs/plans/2026-05-21-19-57-CST-thread-auto-name-source.md create mode 100644 tools/evidence/thread_auto_name_source/AGENTS.md create mode 100755 tools/evidence/thread_auto_name_source/run.sh create mode 100644 tools/evidence/thread_auto_name_source/thread_auto_name_flow.mjs diff --git a/apps/node_backend/src/db/migrations/020_chat_channel_name_source.sql b/apps/node_backend/src/db/migrations/020_chat_channel_name_source.sql new file mode 100644 index 00000000..f4af25b0 --- /dev/null +++ b/apps/node_backend/src/db/migrations/020_chat_channel_name_source.sql @@ -0,0 +1,10 @@ +-- Migration: Track chat channel name source +-- Description: Record whether a display name was manual or automatically derived. +-- Version: 020 +-- Date: 2026-05-21 + +ALTER TABLE chat_channel_names + ADD COLUMN IF NOT EXISTS source VARCHAR(64) NOT NULL DEFAULT 'manual'; + +ALTER TABLE chat_channel_names + ADD COLUMN IF NOT EXISTS generated_name_attempted_at TIMESTAMP NULL; diff --git a/apps/node_backend/src/routes/chat.test.ts b/apps/node_backend/src/routes/chat.test.ts index 73d2ecff..68f825ef 100644 --- a/apps/node_backend/src/routes/chat.test.ts +++ b/apps/node_backend/src/routes/chat.test.ts @@ -20,9 +20,13 @@ const { resolveScopeInstructionsMock, upsertChatScopeSettingMock, deleteChatScopeSettingMock, + claimFirstMessageGeneratedNameAttemptMock, + completeFirstMessageGeneratedNameMock, + insertFirstMessageExactNameIfMissingMock, listChatChannelNamesMock, upsertChatChannelNameMock, deleteChatChannelNameMock, + generateWithUserConfigMock, streamWithAgentToolsAndUserConfigMock, buildAgentToolsMock, getPlatformNodeByNodeIdMock, @@ -66,6 +70,9 @@ const { updatedAt: "2026-04-17T07:00:00.000Z", })), deleteChatScopeSettingMock: vi.fn(async () => ({ deleted: true })), + claimFirstMessageGeneratedNameAttemptMock: vi.fn(async () => null), + completeFirstMessageGeneratedNameMock: vi.fn(async () => null), + insertFirstMessageExactNameIfMissingMock: vi.fn(async () => null), listChatChannelNamesMock: vi.fn(async () => []), upsertChatChannelNameMock: vi.fn(async () => ({ channelId: "channel-1", @@ -75,6 +82,11 @@ const { updatedAt: "2026-04-18T08:00:00.000Z", })), deleteChatChannelNameMock: vi.fn(async () => ({ deleted: true })), + generateWithUserConfigMock: vi.fn(async () => ({ + provider: "anthropic", + model: "claude-sonnet-4-5", + text: "Generated Thread Name", + })), streamWithAgentToolsAndUserConfigMock: vi.fn(async () => ({ textStream: (async function* () { yield "sync "; @@ -138,7 +150,10 @@ vi.mock("../services/platformNodeService.js", () => ({ })); vi.mock("../services/chatChannelNameService.js", () => ({ + claimFirstMessageGeneratedNameAttempt: claimFirstMessageGeneratedNameAttemptMock, + completeFirstMessageGeneratedName: completeFirstMessageGeneratedNameMock, deleteChatChannelName: deleteChatChannelNameMock, + insertFirstMessageExactNameIfMissing: insertFirstMessageExactNameIfMissingMock, listChatChannelNames: listChatChannelNamesMock, upsertChatChannelName: upsertChatChannelNameMock, })); @@ -148,6 +163,7 @@ vi.mock('../services/localAgentLoopService.js', () => ({ })); vi.mock("../llm/llm_service.js", () => ({ + generateWithUserConfig: generateWithUserConfigMock, streamWithAgentToolsAndUserConfig: streamWithAgentToolsAndUserConfigMock, })); @@ -218,9 +234,21 @@ describe("chat routes", () => { threadInstructions: null, }); deleteChatScopeSettingMock.mockClear(); + claimFirstMessageGeneratedNameAttemptMock.mockReset(); + claimFirstMessageGeneratedNameAttemptMock.mockResolvedValue(null); + completeFirstMessageGeneratedNameMock.mockReset(); + completeFirstMessageGeneratedNameMock.mockResolvedValue(null); + insertFirstMessageExactNameIfMissingMock.mockReset(); + insertFirstMessageExactNameIfMissingMock.mockResolvedValue(null); listChatChannelNamesMock.mockClear(); upsertChatChannelNameMock.mockClear(); deleteChatChannelNameMock.mockClear(); + generateWithUserConfigMock.mockReset(); + generateWithUserConfigMock.mockResolvedValue({ + provider: "anthropic", + model: "claude-sonnet-4-5", + text: "Generated Thread Name", + }); streamWithAgentToolsAndUserConfigMock.mockClear(); buildAgentToolsMock.mockClear(); getPlatformNodeByNodeIdMock.mockReset(); @@ -404,7 +432,7 @@ describe("chat routes", () => { expect.any(Object), expect.objectContaining({ maxSteps: 10, - maxToolCalls: 10, + maxToolCalls: 50, timeoutMs: 60000, }), undefined, @@ -425,6 +453,121 @@ describe("chat routes", () => { ]); }); + it("auto-names a non-main thread from the first message and generates one title", async () => { + insertFirstMessageExactNameIfMissingMock.mockResolvedValueOnce({ + channelId: "channel-1", + threadId: "thread-1", + displayName: "explain katago joseki in simple terms", + source: "first_message_exact", + generatedNameAttemptedAt: null, + createdAt: "2026-05-21T00:00:00.000Z", + updatedAt: "2026-05-21T00:00:00.000Z", + } as any); + claimFirstMessageGeneratedNameAttemptMock.mockResolvedValueOnce({ + channelId: "channel-1", + threadId: "thread-1", + displayName: "explain katago joseki in simple terms", + source: "first_message_exact", + generatedNameAttemptedAt: "2026-05-21T00:00:01.000Z", + createdAt: "2026-05-21T00:00:00.000Z", + updatedAt: "2026-05-21T00:00:01.000Z", + } as any); + generateWithUserConfigMock.mockResolvedValueOnce({ + provider: "anthropic", + model: "claude-sonnet-4-5", + text: '"Katago Joseki Basics"', + }); + completeFirstMessageGeneratedNameMock.mockResolvedValueOnce({ + channelId: "channel-1", + threadId: "thread-1", + displayName: "Katago Joseki Basics", + source: "first_message_generated", + generatedNameAttemptedAt: "2026-05-21T00:00:01.000Z", + createdAt: "2026-05-21T00:00:00.000Z", + updatedAt: "2026-05-21T00:00:02.000Z", + } as any); + + const response = await fetch(`${baseUrl}/api/chat/respond`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + taskId: "task-thread-name-1", + idempotencyKey: "idem-thread-name-1", + channelId: "channel-1", + threadId: "thread-1", + sessionId: "session:channel-1:thread-1", + userMessageId: "msg-u-thread-name-1", + assistantMessageId: "msg-a-thread-name-1", + userMessage: "explain katago joseki in simple terms", + }), + }); + + expect(response.status).toBe(200); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(insertFirstMessageExactNameIfMissingMock).toHaveBeenCalledWith( + "user-123", + { + channelId: "channel-1", + threadId: "thread-1", + displayName: "explain katago joseki in simple terms", + }, + ); + expect(claimFirstMessageGeneratedNameAttemptMock).toHaveBeenCalledWith( + "user-123", + { + channelId: "channel-1", + threadId: "thread-1", + }, + ); + expect(generateWithUserConfigMock).toHaveBeenCalledWith( + "user-123", + expect.objectContaining({ + maxTokens: 64, + messages: expect.arrayContaining([ + expect.objectContaining({ + role: "user", + content: "explain katago joseki in simple terms", + }), + ]), + }), + undefined, + ); + expect(completeFirstMessageGeneratedNameMock).toHaveBeenCalledWith( + "user-123", + { + channelId: "channel-1", + threadId: "thread-1", + displayName: "Katago Joseki Basics", + }, + ); + expect(upsertMessagesMock).toHaveBeenCalledWith("user-123", [ + expect.objectContaining({ + messageId: "msg-a-thread-name-1", + metadata: expect.objectContaining({ + invalidations: [ + { + kind: "chat.channelNames", + channelId: "channel-1", + threadId: "thread-1", + }, + ], + }), + }), + ]); + expect(upsertMessagesMock).toHaveBeenCalledWith("user-123", [ + expect.objectContaining({ + messageId: "msg-a-thread-name-1", + metadata: expect.objectContaining({ + autoThreadName: { + source: "first_message_generated", + displayName: "Katago Joseki Basics", + }, + }), + }), + ]); + }); + it('uses model-driven agent loop for all local respond messages, always passing tools to the streaming function', async () => { const response = await fetch(`${baseUrl}/api/chat/respond`, { method: 'POST', @@ -1095,6 +1238,7 @@ describe("chat routes", () => { userMessageId: 'msg-u-tool-limit-1', assistantMessageId: 'msg-a-tool-limit-1', userMessage: 'use many tools', + maxToolCalls: 10, }), }); @@ -1189,7 +1333,7 @@ describe("chat routes", () => { toolCallCount: 1, completedToolCallCount: 1, failedToolCallCount: 0, - maxToolCalls: 10, + maxToolCalls: 50, stepCount: 1, maxSteps: 10, }), diff --git a/apps/node_backend/src/routes/chat.ts b/apps/node_backend/src/routes/chat.ts index 86feead3..80669056 100644 --- a/apps/node_backend/src/routes/chat.ts +++ b/apps/node_backend/src/routes/chat.ts @@ -24,7 +24,10 @@ import { upsertChatScopeSetting, } from "../services/chatRouterService.js"; import { + claimFirstMessageGeneratedNameAttempt, + completeFirstMessageGeneratedName, deleteChatChannelName, + insertFirstMessageExactNameIfMissing, listChatChannelNames, upsertChatChannelName, } from "../services/chatChannelNameService.js"; @@ -33,6 +36,7 @@ import { listPlatformNodes, } from "../services/platformNodeService.js"; import { + generateWithUserConfig, streamWithAgentToolsAndUserConfig, type AgentLoopStreamStopInfo, } from "../llm/llm_service.js"; @@ -61,8 +65,10 @@ const MAX_ASSISTANT_STREAM_OUTPUT_CHARS = 120 * 1024; // Minimum interval between incremental DB flushes during model streaming to avoid write amplification. const STREAM_FLUSH_INTERVAL_MS = 300; const DEFAULT_INTERNAL_LOOP_MAX_STEPS = 10; -const DEFAULT_INTERNAL_LOOP_MAX_TOOL_CALLS = 10; +const DEFAULT_INTERNAL_LOOP_MAX_TOOL_CALLS = 50; const DEFAULT_INTERNAL_LOOP_TIMEOUT_MS = 60000; +const MAX_AUTO_THREAD_NAME_CHARS = 80; +const AUTO_THREAD_TITLE_GENERATION_TIMEOUT_MS = 20_000; type ChatClientInvalidation = | { kind: "chat.scopes"; channelId?: string; threadId?: string | null } @@ -203,6 +209,138 @@ function dedupeInvalidations( return result; } +function isAutoNameEligibleThread(threadId: string | null | undefined): threadId is string { + const normalized = threadId?.trim(); + return Boolean(normalized && normalized.length > 0 && normalized !== "main"); +} + +function cleanAutoThreadName(value: string, maxChars = MAX_AUTO_THREAD_NAME_CHARS): string { + const cleaned = value + .replace(/\s+/g, " ") + .replace(/^["'`“”‘’]+|["'`“”‘’]+$/g, "") + .trim(); + if (cleaned.length <= maxChars) return cleaned; + return cleaned.slice(0, maxChars).trimEnd(); +} + +function deriveFallbackGeneratedThreadName(value: string): string { + const cleaned = cleanAutoThreadName(value); + if (!cleaned) return ""; + const words = cleaned.split(/\s+/).filter(Boolean); + if (words.length <= 6) return cleaned; + return words.slice(0, 6).join(" "); +} + +async function withTimeout( + promise: Promise, + timeoutMs: number, + message: string, +): Promise { + let timeoutHandle: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeoutHandle !== undefined) clearTimeout(timeoutHandle); + } +} + +async function insertFirstMessageExactThreadName(params: { + userId: string; + channelId: string; + threadId: string | null; + userMessage: string; +}): Promise { + if (!isAutoNameEligibleThread(params.threadId)) return []; + const displayName = cleanAutoThreadName(params.userMessage); + if (!displayName) return []; + const inserted = await insertFirstMessageExactNameIfMissing(params.userId, { + channelId: params.channelId, + threadId: params.threadId, + displayName, + }); + return inserted + ? [ + { + kind: "chat.channelNames", + channelId: params.channelId, + threadId: params.threadId, + }, + ] + : []; +} + +async function generateFirstMessageThreadTitle(params: { + userId: string; + channelId: string; + threadId: string | null; + body: Record; +}): Promise { + if (!isAutoNameEligibleThread(params.threadId)) return null; + const claimed = await claimFirstMessageGeneratedNameAttempt(params.userId, { + channelId: params.channelId, + threadId: params.threadId, + }); + if (!claimed) return null; + + const exactName = claimed.displayName.trim(); + if (!exactName) return null; + + let displayName = ""; + try { + const response = await withTimeout( + generateWithUserConfig( + params.userId, + { + model: + typeof params.body.model === "string" ? params.body.model : undefined, + configId: + typeof params.body.configId === "string" + ? params.body.configId + : undefined, + temperature: 0.2, + maxTokens: 64, + messages: [ + { + role: "system", + content: + "Generate a concise chat thread title from the user's first message. " + + "Return only the title. Use the same language as the user's message. " + + "Do not use quotes, markdown, punctuation-only titles, or explanations. " + + "Keep it under 8 words.", + }, + { + role: "user", + content: exactName, + }, + ], + }, + parseProvider(params.body.provider), + ), + AUTO_THREAD_TITLE_GENERATION_TIMEOUT_MS, + `Auto thread title generation timed out after ${AUTO_THREAD_TITLE_GENERATION_TIMEOUT_MS}ms`, + ); + displayName = cleanAutoThreadName(response.text); + } catch (error) { + console.warn( + "Auto thread title model generation failed, using fallback:", + error instanceof Error ? error.message : String(error), + ); + } + if (!displayName) { + displayName = deriveFallbackGeneratedThreadName(exactName); + } + if (!displayName) return null; + const updated = await completeFirstMessageGeneratedName(params.userId, { + channelId: params.channelId, + threadId: params.threadId, + displayName, + }); + return updated ? updated.displayName : null; +} + type AgentLoopStopReasonType = | "tool_call_limit_reached" | "step_limit_reached" @@ -574,7 +712,7 @@ async function runDefaultRouterRespondAsync(params: { const loopMaxToolCalls = parseBoundedInt(body.maxToolCalls, { fallback: DEFAULT_INTERNAL_LOOP_MAX_TOOL_CALLS, min: 1, - max: 20, + max: 50, }); const loopTimeoutMs = parseBoundedInt(body.timeoutMs, { fallback: DEFAULT_INTERNAL_LOOP_TIMEOUT_MS, @@ -606,6 +744,14 @@ async function runDefaultRouterRespondAsync(params: { let completedToolCallCount = 0; let failedToolCallCount = 0; const collectedInvalidations: ChatClientInvalidation[] = []; + collectedInvalidations.push( + ...(await insertFirstMessageExactThreadName({ + userId, + channelId, + threadId, + userMessage, + })), + ); // Always use the agent-loop path so the model is aware of (and can invoke) // internal tools regardless of whether the user typed a slash command or @@ -1036,6 +1182,52 @@ async function runDefaultRouterRespondAsync(params: { createdAt: null, }, ]); + if (finalTaskState === "completed") { + try { + const generatedName = await generateFirstMessageThreadTitle({ + userId, + channelId, + threadId, + body, + }); + if (generatedName) { + await upsertMessages(userId, [ + { + messageId: assistantMessageId, + taskId: acceptedTaskId, + channelId, + sessionId: acceptedSessionId, + threadId, + role: "assistant", + content: finalContent, + taskState: finalTaskState, + checkpointCursor: null, + metadata: { + provider, + streamMode: "model-chunk", + invalidations: [ + { + kind: "chat.channelNames", + channelId, + threadId, + }, + ], + autoThreadName: { + source: "first_message_generated", + displayName: generatedName, + }, + }, + createdAt: null, + }, + ]); + } + } catch (error) { + console.warn( + "Auto thread title generation failed:", + error instanceof Error ? error.message : String(error), + ); + } + } } catch (error) { const message = error instanceof Error ? error.message : String(error); await upsertMessages(userId, [ diff --git a/apps/node_backend/src/services/chatChannelNameService.ts b/apps/node_backend/src/services/chatChannelNameService.ts index 497830e3..cb5e228d 100644 --- a/apps/node_backend/src/services/chatChannelNameService.ts +++ b/apps/node_backend/src/services/chatChannelNameService.ts @@ -4,14 +4,23 @@ interface ChatChannelNameRow { channel_id: string; thread_id: string | null; display_name: string; + source: ChatChannelNameSource; + generated_name_attempted_at: string | null; created_at: string; updated_at: string; } +export type ChatChannelNameSource = + | 'manual' + | 'first_message_exact' + | 'first_message_generated'; + export interface ChatChannelNameSetting { channelId: string; threadId: string | null; displayName: string; + source: ChatChannelNameSource; + generatedNameAttemptedAt: string | null; createdAt: string; updatedAt: string; } @@ -20,6 +29,7 @@ export interface ChatChannelNameInput { channelId: string; threadId?: string | null; displayName: string; + source?: ChatChannelNameSource; } function normalizeThreadId(threadId?: string | null): string { @@ -32,6 +42,8 @@ function toDto(row: ChatChannelNameRow): ChatChannelNameSetting { channelId: row.channel_id, threadId: row.thread_id || null, displayName: row.display_name, + source: row.source ?? 'manual', + generatedNameAttemptedAt: row.generated_name_attempted_at, createdAt: row.created_at, updatedAt: row.updated_at, }; @@ -41,7 +53,7 @@ export async function listChatChannelNames( userId: string, ): Promise { const result = await pool.query( - `SELECT channel_id, thread_id, display_name, created_at, updated_at + `SELECT channel_id, thread_id, display_name, source, generated_name_attempted_at, created_at, updated_at FROM chat_channel_names WHERE user_id = $1 ORDER BY channel_id ASC, thread_id ASC`, @@ -55,19 +67,94 @@ export async function upsertChatChannelName( input: ChatChannelNameInput, ): Promise { const threadId = normalizeThreadId(input.threadId); + const source = input.source ?? 'manual'; const result = await pool.query( - `INSERT INTO chat_channel_names (user_id, channel_id, thread_id, display_name) - VALUES ($1, $2, $3, $4) + `INSERT INTO chat_channel_names (user_id, channel_id, thread_id, display_name, source) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id, channel_id, thread_id) DO UPDATE SET display_name = EXCLUDED.display_name, + source = EXCLUDED.source, + generated_name_attempted_at = CASE + WHEN EXCLUDED.source = 'manual' THEN chat_channel_names.generated_name_attempted_at + ELSE NULL + END, updated_at = CURRENT_TIMESTAMP - RETURNING channel_id, thread_id, display_name, created_at, updated_at`, - [userId, input.channelId, threadId, input.displayName], + RETURNING channel_id, thread_id, display_name, source, generated_name_attempted_at, created_at, updated_at`, + [userId, input.channelId, threadId, input.displayName, source], ); return toDto(result.rows[0]); } +export async function insertFirstMessageExactNameIfMissing( + userId: string, + input: { + channelId: string; + threadId: string; + displayName: string; + }, +): Promise { + const threadId = normalizeThreadId(input.threadId); + if (!threadId) return null; + const result = await pool.query( + `INSERT INTO chat_channel_names (user_id, channel_id, thread_id, display_name, source) + VALUES ($1, $2, $3, $4, 'first_message_exact') + ON CONFLICT (user_id, channel_id, thread_id) DO NOTHING + RETURNING channel_id, thread_id, display_name, source, generated_name_attempted_at, created_at, updated_at`, + [userId, input.channelId, threadId, input.displayName], + ); + return result.rows[0] ? toDto(result.rows[0]) : null; +} + +export async function claimFirstMessageGeneratedNameAttempt( + userId: string, + input: { + channelId: string; + threadId: string; + }, +): Promise { + const threadId = normalizeThreadId(input.threadId); + if (!threadId) return null; + const result = await pool.query( + `UPDATE chat_channel_names + SET generated_name_attempted_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE user_id = $1 + AND channel_id = $2 + AND thread_id = $3 + AND source = 'first_message_exact' + AND generated_name_attempted_at IS NULL + RETURNING channel_id, thread_id, display_name, source, generated_name_attempted_at, created_at, updated_at`, + [userId, input.channelId, threadId], + ); + return result.rows[0] ? toDto(result.rows[0]) : null; +} + +export async function completeFirstMessageGeneratedName( + userId: string, + input: { + channelId: string; + threadId: string; + displayName: string; + }, +): Promise { + const threadId = normalizeThreadId(input.threadId); + if (!threadId) return null; + const result = await pool.query( + `UPDATE chat_channel_names + SET display_name = $4, + source = 'first_message_generated', + updated_at = CURRENT_TIMESTAMP + WHERE user_id = $1 + AND channel_id = $2 + AND thread_id = $3 + AND source = 'first_message_exact' + RETURNING channel_id, thread_id, display_name, source, generated_name_attempted_at, created_at, updated_at`, + [userId, input.channelId, threadId, input.displayName], + ); + return result.rows[0] ? toDto(result.rows[0]) : null; +} + export async function deleteChatChannelName( userId: string, channelId: string, diff --git a/docs/code_maps/feature_map.yaml b/docs/code_maps/feature_map.yaml index 63dd152d..aa1a5541 100644 --- a/docs/code_maps/feature_map.yaml +++ b/docs/code_maps/feature_map.yaml @@ -96,6 +96,7 @@ products: - 未来新增的 plugin 平台(例如 Hermes)应复用 plugin 路由,平台差异留在 node/plugin 配置层。 - 顶部频道名称应支持下拉切换频道,切换后分区标签与分区列表需同步刷新为目标频道的数据。 - AI tool 创建 thread 或改名当前 channel 后,客户端应通过同步 scopes/channel names 刷新顶部名称与 Thread 列表,不需要整页刷新。 + - 非主 Thread 首次收到用户消息时,后端应先在 `chat_channel_names` 写入 `source=first_message_exact` 的可读名;本轮 assistant 成功完成后仅尝试一次 `first_message_generated`,成功或 fallback 后通过 typed invalidation 刷新 Thread 名称。 - 顶部频道下拉菜单顶部应显示 `Rename` / `Archive` 功能分组;默认频道下这两个功能禁用,非默认频道下应调用频道改名与归档持久化路径;频道很多时菜单应限制在合理最大高度内并支持内部滚动。 - 频道名称右侧应内嵌分区下拉按钮(显示当前分区名+下箭头),且该菜单与频道标题左对齐,不再使用右上角三个点。 - Thread 菜单主线程显示为 `Thread`;菜单应先显示 `New Thread` 功能组;在子线程内应显示小号浅色“当前组”标题及 `Rename` / `Archive` 操作;主线程不显示当前组操作;底部列表组显示主线程和已有子线程;菜单弹出动画应保持快速缩放+淡入。 @@ -245,7 +246,7 @@ products: - 在 Turso/libSQL 数据库下,table_create / table_add_column / table_add_row / table_update_row / table_delete_row 均应成功执行,不应因 PostgreSQL-only SQL(例如 NOW() 或 ::jsonb)失败。 - 当 table_create 执行时抛出数据库错误,聊天历史应显示工具错误结果,并将对应 tool_call_start 标记为已完成,不能永久显示“正在调用”。 - 相邻工具调用在聊天流中应折叠为一个 `Thinking` / `Thinking complete completed/total` 标志,不直接显示工具名或长 JSON;同 task 的 thinking 行应显示在 `Processing...` 占位前;点击 thinking 标志可在模态窗口查看具体 tool/reasoning 过程;紧随其后的助手文本不重复显示头像/模型名。 - - 默认 agent-loop 预算为 10 steps / 10 tool calls,单步模型流超时为 60s;如果 tool call 后未产生最终文本,后端必须写入非空失败消息并说明是 tool-call limit、step limit、真实 timeout、stream error 或 empty-final-after-tools,而不是写入空 completed assistant 消息。 + - 默认 agent-loop 预算为 10 steps / 50 tool calls,单步模型流超时为 60s;如果 tool call 后未产生最终文本,后端必须写入非空失败消息并说明是 tool-call limit、step limit、真实 timeout、stream error 或 empty-final-after-tools,而不是写入空 completed assistant 消息。 - 添加一列 Priority 后,已有行不变(O(1) schema change)。 - 软删除行后,table_get 不返回该行。 diff --git a/docs/code_maps/logic_map.yaml b/docs/code_maps/logic_map.yaml index 77690b66..6aa7ce32 100644 --- a/docs/code_maps/logic_map.yaml +++ b/docs/code_maps/logic_map.yaml @@ -120,6 +120,7 @@ index: - apps/node_backend/src/services/chatAsyncTransportService.ts - apps/node_backend/src/llm/llm_service.ts - apps/node_backend/src/db/migrations/019_scope_chat_channel_names.sql + - apps/node_backend/src/db/migrations/020_chat_channel_name_source.sql doc_index: - docs/intro.md - docs/product/overview.md @@ -261,8 +262,9 @@ index: - 若本地自定义 Agent 文件读取与线上会话初始化共用同一个失败边界,单个本地文件系统异常会误伤频道和历史加载。 - ChatHistoryApiService 依赖 AuthenticatedApiClient 注入 token;若重新在 ChatScreen 用 _authToken 阻断 load/respond/SSE,会回到 UI 静默失败路径。 - 相邻 tool_call_start/tool_call 消息应在 MessageList 中折叠成一个 Thinking/Thinking complete 标志,并支持点击查看详情;同 task 的 tool group 应显示在 Processing 占位前;紧随其后的普通 assistant 文本会隐藏重复 header。如果分组边界跨过普通 assistant/user 消息,会错误隐藏正常对话内容。 - - agent loop 默认预算应保持 10 steps / 10 tool calls,单步模型流超时默认为 60s;当 tool calls 已发生但 final text stream 为空时,后端必须把最终 assistant row 写成带真实 stop reason 的 failed 消息,并且只有实际 timeout 路径才能标记为 timeout_reached。 + - agent loop 默认预算应保持 10 steps / 50 tool calls,单步模型流超时默认为 60s;当 tool calls 已发生但 final text stream 为空时,后端必须把最终 assistant row 写成带真实 stop reason 的 failed 消息,并且只有实际 timeout 路径才能标记为 timeout_reached。 - AI tool 修改当前 channel name 或创建 thread 后,ChatScreen 需要在 SSE snapshot 后刷新 scopes/channel names/scope settings,否则顶部 channel 名与 Thread 列表会等到整页刷新后才更新。 + - Thread 自动命名必须只在非 main Thread 上运行;`manual` 名称不能被自动覆盖,`first_message_generated` 每个 Thread 只能从 `first_message_exact` 抢占一次,模型生成失败或超时时需要 fallback 完成状态,避免永久停在 attempted。 - 若组件直接写死颜色或误用 `primary` 作为普通气泡背景,会破坏暗色信息层级并导致关键行动不突出。 - 若频道最新消息时间未在消息加载、追加、归档时同步更新,侧边栏排序将与实际活跃时间脱节。 - 若仅对侧边栏频道列表使用 _sortedChannels 而未对顶部频道下拉菜单同步更新,两处展示顺序不一致会混淆用户。 diff --git a/docs/plans/2026-05-21-16-43-CST-agent-loop-limit-empty-final.md b/docs/plans/2026-05-21-16-43-CST-agent-loop-limit-empty-final.md index f0fa8fbc..0c8a04d4 100644 --- a/docs/plans/2026-05-21-16-43-CST-agent-loop-limit-empty-final.md +++ b/docs/plans/2026-05-21-16-43-CST-agent-loop-limit-empty-final.md @@ -6,7 +6,7 @@ A production task reached the internal tool-call budget and persisted an empty c ## Goals -- Increase the default model-driven agent-loop budget to 10 steps and 10 tool calls. +- Increase the default model-driven agent-loop budget to 10 steps and 50 tool calls. - Never persist an empty completed assistant response after tool calls. - Make budget termination visible to users with a clear explanation that the internal step or tool-call limit was reached. - Preserve metadata that explains the stop reason for future debugging. @@ -21,7 +21,7 @@ A production task reached the internal tool-call budget and persisted an empty c ## Acceptance Criteria -- Default respond requests pass `maxSteps: 10` and `maxToolCalls: 10` to the agent loop. +- Default respond requests pass `maxSteps: 10` and `maxToolCalls: 50` to the agent loop. - If tool calls occurred and no final text is produced, the persisted assistant message has non-empty explanatory content. - If the tool-call or step limit was reached, the message says which internal limit was reached. - Tests cover the regression that previously wrote an empty completed assistant message. diff --git a/docs/plans/2026-05-21-19-57-CST-thread-auto-name-source.md b/docs/plans/2026-05-21-19-57-CST-thread-auto-name-source.md new file mode 100644 index 00000000..f78b3c3a --- /dev/null +++ b/docs/plans/2026-05-21-19-57-CST-thread-auto-name-source.md @@ -0,0 +1,42 @@ +# Thread Auto Name Source + +## Background + +Thread display names are stored separately from scope settings in `chat_channel_names`. +New Channels already have a user-entered name, but new Threads can be created +without a display name and then appear as raw thread IDs until a manual rename +or a later refresh path supplies a name. + +## Goals + +- Keep the existing name table model. +- Add source metadata for display names. +- Give unnamed non-main Threads an immediate exact name from the first message. +- After the first assistant reply completes, attempt one generated title for + Threads still named by `first_message_exact`. +- Avoid extra frontend requests and avoid overwriting manual names. + +## Implementation Plan + +1. Extend `chat_channel_names` with `source` and `generated_name_attempted_at`. +2. Add service helpers for exact-name insertion, generation claiming, and + conditional generated-name completion. +3. Invoke the helpers from the backend `/api/chat/respond` async flow. +4. Notify the existing frontend through typed `chat.channelNames` invalidations. +5. Add backend unit coverage and a repeatable evidence harness. + +## Acceptance Criteria + +- Existing UI/API/manual rename paths write `source = manual`. +- For non-main Threads with no name, the first user message inserts + `source = first_message_exact`. +- Only one generated-name attempt can be claimed per Thread. +- A generated name only updates rows still at `source = first_message_exact`. +- Manual names are never overwritten by automatic generation. +- E2E evidence proves exact and generated naming through the real app/API path. + +## Validation Commands + +- `cd apps/node_backend && npm test -- src/routes/chat.test.ts src/services/localAgentLoopService.test.ts` +- `cd apps/node_backend && npm run type-check` +- `tools/evidence/thread_auto_name_source/run.sh` diff --git a/tools/evidence/thread_auto_name_source/AGENTS.md b/tools/evidence/thread_auto_name_source/AGENTS.md new file mode 100644 index 00000000..36601fe3 --- /dev/null +++ b/tools/evidence/thread_auto_name_source/AGENTS.md @@ -0,0 +1,109 @@ +# Thread Auto Name Source Evidence Case + +## Requirement + +When a user sends the first message in a non-main Thread, the backend should +give that Thread a usable display name without requiring an extra frontend +request: + +1. Insert `first_message_exact` immediately from the first user message when no + Thread name exists. +2. After the assistant reply completes, claim one generated-name attempt. +3. Replace the exact name with `first_message_generated` only if the row is + still `first_message_exact`. +4. Never run the generated-name attempt more than once for the same Thread. + +## Baseline Behavior To Prove + +Before a respond request, the case-specific `channelId + threadId` must have no +name row in `/api/chat/channel-names`. + +## Fixed Behavior To Prove + +After the first respond request completes, using API for the user action and DB +checks for low-request evidence: + +- the Thread has a name row +- `source` is `first_message_generated` +- `generatedNameAttemptedAt` is non-null +- the final display name differs from the raw first message when the configured + model generates a better title + +After a second respond request in the same Thread: + +- `generatedNameAttemptedAt` is unchanged +- the display name is unchanged +- `source` remains `first_message_generated` + +## Fixture/Data Shape + +The harness creates a unique channel/thread pair per run: + +- `channelId`: `e2e-auto-name-` +- `threadId`: `thread-` +- `sessionId`: `session::` + +The test user is `FIXTURE_USER_ID`, authenticated by `BRICKS_TEST_TOKEN`. + +## Required Environment Variables + +Loaded from repo-root `.env.local`: + +- `BRICKS_TEST_TOKEN` +- `FIXTURE_USER_ID` +- `JWT_SECRET` +- database configuration used by the local backend +- local LLM configuration or persisted user LLM config + +Optional: + +- `BRICKS_API_BASE_URL` defaults to `http://127.0.0.1:3011` +- `PORT` defaults to `3011` +- `KEEP_SERVICES=1` keeps a backend started by this harness running +- `RUN_MIGRATIONS=1` applies pending migrations before the flow + +## Run Command + +```sh +tools/evidence/thread_auto_name_source/run.sh +``` + +## Evidence Output + +Evidence is written under: + +```text +.cache/evidence/thread-auto-name-source// +``` + +Expected files: + +- `summary.json` +- `-auth-me.json` +- `-names-before.json` +- `-respond-first.json` +- `-names-after-first.json` +- `-respond-second.json` +- `-names-after-second.json` +- optional backend/migration logs + +## Checkpoints + +- `authMe`: test token resolves to the expected fixture user. +- `backendReady`: local backend responds on `/api/health`. +- `baselineNoName`: the generated fixture Thread has no name row before the flow. +- `firstRespondAccepted`: `/api/chat/respond` accepts the first message. +- `generatedNameVisible`: `/api/chat/channel-names` shows `first_message_generated`. +- `secondRespondAccepted`: a second message is accepted in the same Thread. +- `generationRunsOnce`: source/name/attempt timestamp do not change after the second message. + +## Failure Reading Notes + +- If `authMe` fails, check `BRICKS_TEST_TOKEN`, `JWT_SECRET`, and `FIXTURE_USER_ID`. +- If `generatedNameVisible` stays at `first_message_exact`, inspect backend logs + for local LLM config or provider errors. +- If `generationRunsOnce` fails, the generated attempt claim is not guarded + tightly enough. +- The harness intentionally checks the name row through the database instead of + repeatedly polling `/api/chat/channel-names`, so it does not create false 429 + failures while validating the user-visible API flow. diff --git a/tools/evidence/thread_auto_name_source/run.sh b/tools/evidence/thread_auto_name_source/run.sh new file mode 100755 index 00000000..ae394ba9 --- /dev/null +++ b/tools/evidence/thread_auto_name_source/run.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +CASE_DIR="$ROOT_DIR/tools/evidence/thread_auto_name_source" +RUN_ID="${RUN_ID:-$(date +%Y%m%d-%H%M%S)}" +EVIDENCE_DIR="$ROOT_DIR/.cache/evidence/thread-auto-name-source/$RUN_ID" +BACKEND_DIR="$ROOT_DIR/apps/node_backend" +BACKEND_STARTED=0 +BACKEND_PID="" + +mkdir -p "$EVIDENCE_DIR" +REQUESTED_PORT="${PORT:-}" +REQUESTED_API_BASE_URL="${BRICKS_API_BASE_URL:-}" + +cleanup() { + local status=$? + if [[ "${KEEP_SERVICES:-0}" != "1" ]]; then + if [[ "$BACKEND_STARTED" == "1" && -n "$BACKEND_PID" ]]; then + kill "$BACKEND_PID" >/dev/null 2>&1 || true + fi + fi + exit "$status" +} +trap cleanup EXIT + +if [[ ! -f "$ROOT_DIR/.env.local" ]]; then + echo "Missing $ROOT_DIR/.env.local" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1091 +source "$ROOT_DIR/.env.local" +set +a + +export PORT="${REQUESTED_PORT:-${PORT:-3011}}" +export BRICKS_API_BASE_URL="${REQUESTED_API_BASE_URL:-http://127.0.0.1:$PORT}" +export REPO_ROOT="$ROOT_DIR" +export RUN_ID +export EVIDENCE_DIR +export NODE_ENV="${NODE_ENV:-development}" +export BRICKS_LOCAL_DEV="${BRICKS_LOCAL_DEV:-true}" +if [[ -n "${GEMINI_API_KEY:-}" && -z "${LOCAL_LLM_CONFIG_ENABLED:-}" ]]; then + export LOCAL_LLM_CONFIG_ENABLED=true +fi + +required_vars=( + BRICKS_TEST_TOKEN + FIXTURE_USER_ID + JWT_SECRET + BRICKS_API_BASE_URL +) + +for name in "${required_vars[@]}"; do + if [[ -z "${!name:-}" ]]; then + echo "Missing required env var: $name" >&2 + exit 1 + fi +done + +http_code() { + curl -sS -o /dev/null -w '%{http_code}' "$1" 2>/dev/null || true +} + +wait_for_http() { + local url="$1" + local label="$2" + local attempts="${3:-90}" + for _ in $(seq 1 "$attempts"); do + if [[ "$(http_code "$url")" == "200" ]]; then + return 0 + fi + sleep 1 + done + echo "Timed out waiting for $label at $url" >&2 + return 1 +} + +if [[ "${RUN_MIGRATIONS:-1}" == "1" ]]; then + ( + cd "$BACKEND_DIR" + set -a + # shellcheck disable=SC1091 + source "$ROOT_DIR/.env.local" + set +a + npx tsx src/db/migrate.ts + ) >"$EVIDENCE_DIR/$RUN_ID-migrate.log" 2>&1 +fi + +if [[ "$(http_code "$BRICKS_API_BASE_URL/api/health")" != "200" ]]; then + ( + cd "$BACKEND_DIR" + PORT="$PORT" AUTO_MIGRATE="${AUTO_MIGRATE:-false}" npm run dev + ) >"$EVIDENCE_DIR/$RUN_ID-backend.log" 2>&1 & + BACKEND_PID="$!" + BACKEND_STARTED=1 +fi + +wait_for_http "$BRICKS_API_BASE_URL/api/health" "backend" + +node "$CASE_DIR/thread_auto_name_flow.mjs" + +echo +echo "Evidence: $EVIDENCE_DIR" +if [[ -f "$EVIDENCE_DIR/summary.json" ]]; then + node -e "const fs=require('node:fs'); const s=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); console.log(JSON.stringify(s, null, 2));" "$EVIDENCE_DIR/summary.json" +fi diff --git a/tools/evidence/thread_auto_name_source/thread_auto_name_flow.mjs b/tools/evidence/thread_auto_name_source/thread_auto_name_flow.mjs new file mode 100644 index 00000000..1f98140c --- /dev/null +++ b/tools/evidence/thread_auto_name_source/thread_auto_name_flow.mjs @@ -0,0 +1,274 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const runId = requiredEnv('RUN_ID'); +const evidenceDir = requiredEnv('EVIDENCE_DIR'); +const apiBaseUrl = requiredEnv('BRICKS_API_BASE_URL').replace(/\/$/, ''); +const repoRoot = requiredEnv('REPO_ROOT'); +const token = requiredEnv('BRICKS_TEST_TOKEN'); +const expectedUserId = requiredEnv('FIXTURE_USER_ID'); +const caseName = 'thread-auto-name-source'; +const backendRequire = createRequire( + path.join(repoRoot, 'apps/node_backend/package.json'), +); +const { createClient: createLibsqlClient } = backendRequire('@libsql/client'); + +const fixtureSafeRunId = runId.replace(/[^a-zA-Z0-9-]/g, '-'); +const channelId = `e2e-auto-name-${fixtureSafeRunId}`; +const threadId = `thread-${fixtureSafeRunId}`; +const sessionId = `session:${channelId}:${threadId}`; +const firstMessage = + `thread title smoke test ${fixtureSafeRunId} for automatic naming verification`; +const secondMessage = `second smoke message ${fixtureSafeRunId}`; + +const summary = { + runId, + caseName, + apiBaseUrl, + evidenceDir, + fixture: { + channelId, + threadId, + sessionId, + firstMessage, + }, + checks: {}, + files: {}, + observations: {}, + diagnosis: null, +}; + +function requiredEnv(name) { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`Missing required env var: ${name}`); + return value; +} + +function artifact(name) { + return `${runId}-${name}`; +} + +async function writeJson(name, value) { + const filePath = path.join(evidenceDir, name); + await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`); + summary.files[name.replace(/\.json$/, '')] = filePath; +} + +async function checkpoint(name, fn) { + try { + const value = await fn(); + summary.checks[name] = 'pass'; + await writeJson('summary.json', summary); + return value; + } catch (error) { + summary.checks[name] = 'fail'; + summary.diagnosis = diagnose(name); + summary.error = { + check: name, + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }; + await writeJson('summary.json', summary); + throw error; + } +} + +function diagnose(check) { + switch (check) { + case 'authMe': + return 'The test token was not accepted by the local API.'; + case 'backendReady': + return 'The local backend health endpoint is not ready.'; + case 'baselineNoName': + return 'The fixture Thread already has a name row before the test.'; + case 'firstRespondAccepted': + return 'The first respond request was not accepted.'; + case 'generatedNameVisible': + return 'The Thread name did not reach source=first_message_generated.'; + case 'secondRespondAccepted': + return 'The second respond request was not accepted.'; + case 'generationRunsOnce': + return 'The second message changed the generated name or attempted timestamp.'; + default: + return `Checkpoint failed: ${check}`; + } +} + +async function api(pathname, options = {}) { + const response = await fetch(`${apiBaseUrl}${pathname}`, { + ...options, + headers: { + Authorization: `Bearer ${token}`, + ...(options.headers || {}), + }, + }); + const text = await response.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = text; + } + return { status: response.status, body }; +} + +function createDbClient() { + const tursoUrl = process.env.TURSO_DATABASE_URL?.trim(); + if (!tursoUrl) { + throw new Error('This evidence flow requires TURSO_DATABASE_URL for DB checkpoints.'); + } + return createLibsqlClient({ + url: tursoUrl, + authToken: process.env.TURSO_AUTH_TOKEN?.trim() || undefined, + }); +} + +const db = createDbClient(); + +async function readThreadNameRow() { + const result = await db.execute({ + sql: + `SELECT channel_id, thread_id, display_name, source, generated_name_attempted_at, created_at, updated_at + FROM chat_channel_names + WHERE user_id = ? + AND channel_id = ? + AND thread_id = ? + LIMIT 1`, + args: [expectedUserId, channelId, threadId], + }); + const row = result.rows[0]; + if (!row) return null; + return { + channelId: String(row.channel_id), + threadId: String(row.thread_id), + displayName: String(row.display_name), + source: String(row.source), + generatedNameAttemptedAt: + row.generated_name_attempted_at == null + ? null + : String(row.generated_name_attempted_at), + createdAt: row.created_at == null ? null : String(row.created_at), + updatedAt: row.updated_at == null ? null : String(row.updated_at), + }; +} + +async function pollThreadName(predicate, timeoutMs = 120000) { + const deadline = Date.now() + timeoutMs; + let last = null; + while (Date.now() < deadline) { + last = await readThreadNameRow(); + if (last && predicate(last)) return last; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error(`Timed out waiting for thread name. Last row: ${JSON.stringify(last)}`); +} + +async function sendMessage(message, index) { + return api('/api/chat/respond', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + taskId: `task-${fixtureSafeRunId}-${index}`, + idempotencyKey: `idem-${fixtureSafeRunId}-${index}`, + channelId, + threadId, + sessionId, + userMessageId: `msg-u-${fixtureSafeRunId}-${index}`, + assistantMessageId: `msg-a-${fixtureSafeRunId}-${index}`, + userMessage: message, + systemPrompt: 'Reply with exactly "OK". Do not call tools.', + }), + }); +} + +await checkpoint('backendReady', async () => { + const health = await fetch(`${apiBaseUrl}/api/health`); + const body = await health.json().catch(() => null); + await writeJson(artifact('health.json'), { status: health.status, body }); + if (health.status !== 200) throw new Error(`/api/health returned ${health.status}`); +}); + +await checkpoint('authMe', async () => { + const result = await api('/api/auth/me'); + await writeJson(artifact('auth-me.json'), result); + if (result.status !== 200) throw new Error(`/api/auth/me returned ${result.status}`); + const userId = result.body?.user?.id ?? result.body?.id ?? result.body?.userId; + if (userId !== expectedUserId) { + throw new Error(`Expected user ${expectedUserId}, got ${userId}`); + } +}); + +await checkpoint('baselineNoName', async () => { + const existing = await readThreadNameRow(); + await writeJson(artifact('names-before.json'), { row: existing }); + if (existing) throw new Error(`Fixture Thread already had a name: ${JSON.stringify(existing)}`); +}); + +await checkpoint('firstRespondAccepted', async () => { + const result = await sendMessage(firstMessage, 1); + await writeJson(artifact('respond-first.json'), result); + if (result.status !== 200) throw new Error(`/api/chat/respond returned ${result.status}`); + if (result.body?.state !== 'accepted') { + throw new Error(`Expected accepted state, got ${JSON.stringify(result.body)}`); + } +}); + +const generatedRow = await checkpoint('generatedNameVisible', async () => { + const firstObservedRow = await pollThreadName( + (item) => + item.source === 'first_message_exact' || + item.source === 'first_message_generated', + 30000, + ); + await writeJson(artifact('names-after-first-observed.json'), { + row: firstObservedRow, + }); + const row = await pollThreadName( + (item) => item.source === 'first_message_generated', + ); + await writeJson(artifact('names-after-first.json'), { + row, + }); + if (!row.generatedNameAttemptedAt) { + throw new Error(`generatedNameAttemptedAt was empty: ${JSON.stringify(row)}`); + } + if (row.displayName === firstMessage) { + throw new Error(`Generated name did not differ from exact first message: ${row.displayName}`); + } + summary.observations.generatedRow = row; + return row; +}); + +await checkpoint('secondRespondAccepted', async () => { + const result = await sendMessage(secondMessage, 2); + await writeJson(artifact('respond-second.json'), result); + if (result.status !== 200) throw new Error(`/api/chat/respond returned ${result.status}`); + if (result.body?.state !== 'accepted') { + throw new Error(`Expected accepted state, got ${JSON.stringify(result.body)}`); + } +}); + +await checkpoint('generationRunsOnce', async () => { + await new Promise((resolve) => setTimeout(resolve, 6000)); + const row = await readThreadNameRow(); + await writeJson(artifact('names-after-second.json'), { + row, + }); + if (!row) throw new Error('Thread name row disappeared'); + if (row.source !== 'first_message_generated') { + throw new Error(`Expected generated source, got ${row.source}`); + } + if (row.displayName !== generatedRow.displayName) { + throw new Error(`Name changed after second message: ${row.displayName}`); + } + if (row.generatedNameAttemptedAt !== generatedRow.generatedNameAttemptedAt) { + throw new Error( + `Attempt timestamp changed after second message: ${row.generatedNameAttemptedAt}`, + ); + } + summary.observations.afterSecondRow = row; +}); + +summary.diagnosis = 'Thread auto naming generated once and stayed stable.'; +await writeJson('summary.json', summary); From 6f48f890b600facc57d9a9a2507fb81aa472a0bf Mon Sep 17 00:00:00 2001 From: ZHOU Date: Mon, 25 May 2026 17:59:56 +0800 Subject: [PATCH 2/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/node_backend/src/routes/chat.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/apps/node_backend/src/routes/chat.ts b/apps/node_backend/src/routes/chat.ts index 80669056..4946f68b 100644 --- a/apps/node_backend/src/routes/chat.ts +++ b/apps/node_backend/src/routes/chat.ts @@ -744,14 +744,23 @@ async function runDefaultRouterRespondAsync(params: { let completedToolCallCount = 0; let failedToolCallCount = 0; const collectedInvalidations: ChatClientInvalidation[] = []; - collectedInvalidations.push( - ...(await insertFirstMessageExactThreadName({ + try { + collectedInvalidations.push( + ...(await insertFirstMessageExactThreadName({ + userId, + channelId, + threadId, + userMessage, + })), + ); + } catch (error) { + console.warn("Failed to persist exact thread name; continuing chat response", { userId, channelId, threadId, - userMessage, - })), - ); + error, + }); + } // Always use the agent-loop path so the model is aware of (and can invoke) // internal tools regardless of whether the user typed a slash command or From a2329f2acfd5c90af353c4be0b9799f464c711b9 Mon Sep 17 00:00:00 2001 From: ZHOU Date: Mon, 25 May 2026 18:00:37 +0800 Subject: [PATCH 3/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/node_backend/src/routes/chat.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/node_backend/src/routes/chat.ts b/apps/node_backend/src/routes/chat.ts index 4946f68b..1a6dbdcf 100644 --- a/apps/node_backend/src/routes/chat.ts +++ b/apps/node_backend/src/routes/chat.ts @@ -1215,11 +1215,19 @@ async function runDefaultRouterRespondAsync(params: { provider, streamMode: "model-chunk", invalidations: [ - { - kind: "chat.channelNames", - channelId, - threadId, - }, + ...new Map( + [ + ...finalInvalidations, + { + kind: "chat.channelNames", + channelId, + threadId, + }, + ].map((invalidation) => [ + JSON.stringify(invalidation), + invalidation, + ]), + ).values(), ], autoThreadName: { source: "first_message_generated", From e0e13f49116daafaf0dbf9224abf3154aac44c93 Mon Sep 17 00:00:00 2001 From: ZHOU Date: Mon, 25 May 2026 18:02:11 +0800 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/node_backend/src/services/chatChannelNameService.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/node_backend/src/services/chatChannelNameService.ts b/apps/node_backend/src/services/chatChannelNameService.ts index cb5e228d..be62586d 100644 --- a/apps/node_backend/src/services/chatChannelNameService.ts +++ b/apps/node_backend/src/services/chatChannelNameService.ts @@ -75,10 +75,7 @@ export async function upsertChatChannelName( DO UPDATE SET display_name = EXCLUDED.display_name, source = EXCLUDED.source, - generated_name_attempted_at = CASE - WHEN EXCLUDED.source = 'manual' THEN chat_channel_names.generated_name_attempted_at - ELSE NULL - END, + generated_name_attempted_at = chat_channel_names.generated_name_attempted_at, updated_at = CURRENT_TIMESTAMP RETURNING channel_id, thread_id, display_name, source, generated_name_attempted_at, created_at, updated_at`, [userId, input.channelId, threadId, input.displayName, source], From 9831e910dddcd0936567eb7a8b3c67a2a1f309b6 Mon Sep 17 00:00:00 2001 From: ZHOU Date: Mon, 25 May 2026 18:02:52 +0800 Subject: [PATCH 5/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../evidence/thread_auto_name_source/thread_auto_name_flow.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/evidence/thread_auto_name_source/thread_auto_name_flow.mjs b/tools/evidence/thread_auto_name_source/thread_auto_name_flow.mjs index 1f98140c..37238d70 100644 --- a/tools/evidence/thread_auto_name_source/thread_auto_name_flow.mjs +++ b/tools/evidence/thread_auto_name_source/thread_auto_name_flow.mjs @@ -46,7 +46,7 @@ function requiredEnv(name) { } function artifact(name) { - return `${runId}-${name}`; + return `${fixtureSafeRunId}-${name}`; } async function writeJson(name, value) { From 823ec0a598c10cd82e5a46d81fae27a78faa5492 Mon Sep 17 00:00:00 2001 From: ZHOU Date: Mon, 25 May 2026 18:03:12 +0800 Subject: [PATCH 6/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/node_backend/src/services/chatChannelNameService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/node_backend/src/services/chatChannelNameService.ts b/apps/node_backend/src/services/chatChannelNameService.ts index be62586d..58940d19 100644 --- a/apps/node_backend/src/services/chatChannelNameService.ts +++ b/apps/node_backend/src/services/chatChannelNameService.ts @@ -146,6 +146,7 @@ export async function completeFirstMessageGeneratedName( AND channel_id = $2 AND thread_id = $3 AND source = 'first_message_exact' + AND generated_name_attempted_at IS NOT NULL RETURNING channel_id, thread_id, display_name, source, generated_name_attempted_at, created_at, updated_at`, [userId, input.channelId, threadId, input.displayName], );