From 824b1967ff2a05626df0438b72472e409f4a2f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 9 Apr 2026 20:40:07 +0800 Subject: [PATCH] fix(adapters): fix IM message delivery issues and improve UX - Fix WebSocket race condition: wait for connection to open before sending first message, preventing silent message loss after pairing or project selection - Fix response text appearing above tool calls: finalize placeholder before tool_use blocks so post-tool text gets a new message - Remove noisy tool_use and tool_result messages from IM output; thinking indicator is preserved, details visible in Desktop - Fix /new command using default project dir instead of always showing project picker - Fix duplicate projects in list by deduping on realPath instead of projectPath - Improve formatToolUse with human-readable summaries for common tools - Add send failure feedback to users Co-Authored-By: Claude Opus 4.6 (1M context) --- adapters/common/format.ts | 68 ++++++++++++++++++++++++++++++++++++ adapters/common/ws-bridge.ts | 30 +++++++++++++++- adapters/feishu/index.ts | 60 +++++++++++++++++++++++-------- adapters/telegram/index.ts | 60 +++++++++++++++++++++++-------- src/server/api/sessions.ts | 37 ++++++++++---------- 5 files changed, 207 insertions(+), 48 deletions(-) diff --git a/adapters/common/format.ts b/adapters/common/format.ts index a1ecdf76..418f762b 100644 --- a/adapters/common/format.ts +++ b/adapters/common/format.ts @@ -33,10 +33,78 @@ export function splitMessage(text: string, limit: number): string[] { /** Format tool use info for display in IM. */ export function formatToolUse(toolName: string, input: unknown): string { + const inp = (input && typeof input === 'object' ? input : {}) as Record + const summary = formatToolSummary(toolName, inp) + if (summary) return `🔧 ${toolName} ${summary}` const preview = truncateInput(input, 200) return `🔧 ${toolName}\n${preview}` } +/** Generate a concise human-readable summary for common tools. */ +function formatToolSummary(tool: string, inp: Record): string | null { + switch (tool) { + case 'Bash': { + const desc = inp.description as string | undefined + const cmd = inp.command as string | undefined + if (desc) return desc + if (cmd) return truncate(cmd, 120) + return null + } + case 'Read': { + const fp = inp.file_path as string | undefined + if (fp) return shortPath(fp) + return null + } + case 'Edit': { + const fp = inp.file_path as string | undefined + if (fp) return shortPath(fp) + return null + } + case 'Write': { + const fp = inp.file_path as string | undefined + if (fp) return shortPath(fp) + return null + } + case 'Grep': { + const pat = inp.pattern as string | undefined + const p = inp.path as string | undefined + if (pat) return `"${truncate(pat, 60)}"` + (p ? ` in ${shortPath(p)}` : '') + return null + } + case 'Glob': { + const pat = inp.pattern as string | undefined + return pat ? `"${pat}"` : null + } + case 'Skill': { + const skill = inp.skill as string | undefined + return skill || null + } + case 'Agent': { + const desc = inp.description as string | undefined + return desc || null + } + case 'WebFetch': { + const url = inp.url as string | undefined + return url ? truncate(url, 120) : null + } + case 'WebSearch': { + const q = inp.query as string | undefined + return q ? `"${truncate(q, 80)}"` : null + } + default: + return null + } +} + +function shortPath(fp: string): string { + const parts = fp.split('/') + return parts.length > 3 ? '…/' + parts.slice(-3).join('/') : fp +} + +function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max) + '…' : s +} + /** Format a permission request for display in IM. */ export function formatPermissionRequest(toolName: string, input: unknown, requestId: string): string { const preview = truncateInput(input, 300) diff --git a/adapters/common/ws-bridge.ts b/adapters/common/ws-bridge.ts index c09e33da..a82da6f9 100644 --- a/adapters/common/ws-bridge.ts +++ b/adapters/common/ws-bridge.ts @@ -152,9 +152,37 @@ export class WsBridge { }) } + /** Wait until the WebSocket for chatId is open. Resolves false on timeout or error. */ + waitForOpen(chatId: string, timeoutMs = 10_000): Promise { + const session = this.sessions.get(chatId) + if (!session) return Promise.resolve(false) + if (session.ws.readyState === WebSocket.OPEN) return Promise.resolve(true) + return new Promise((resolve) => { + const timer = setTimeout(() => { + cleanup() + resolve(false) + }, timeoutMs) + const onOpen = () => { cleanup(); resolve(true) } + const onError = () => { cleanup(); resolve(false) } + const onClose = () => { cleanup(); resolve(false) } + const cleanup = () => { + clearTimeout(timer) + session.ws.removeListener('open', onOpen) + session.ws.removeListener('error', onError) + session.ws.removeListener('close', onClose) + } + session.ws.once('open', onOpen) + session.ws.once('error', onError) + session.ws.once('close', onClose) + }) + } + private send(chatId: string, message: Record): boolean { const session = this.sessions.get(chatId) - if (!session || session.ws.readyState !== WebSocket.OPEN) return false + if (!session || session.ws.readyState !== WebSocket.OPEN) { + console.warn(`[WsBridge] Cannot send to ${chatId}: session not ready`) + return false + } session.ws.send(JSON.stringify(message)) return true } diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index 82baedd2..3fac0f9a 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -210,7 +210,7 @@ async function ensureSession(chatId: string): Promise { if (stored) { bridge.connectSession(chatId, stored.sessionId) bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) - return true + return await bridge.waitForOpen(chatId) } const workDir = config.defaultProjectDir @@ -228,6 +228,11 @@ async function createSessionForChat(chatId: string, workDir: string): Promise handleServerMessage(chatId, msg)) + const opened = await bridge.waitForOpen(chatId) + if (!opened) { + await sendText(chatId, '⚠️ 连接服务器超时,请重试。') + return false + } return true } catch (err) { await sendText(chatId, `❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`) @@ -274,11 +279,27 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< break case 'content_start': - if (msg.blockType === 'text' && !state.replyMessageId) { - const mid = await sendText(chatId, '▍') - if (mid) { - state.replyMessageId = mid - accumulatedText.set(chatId, '') + if (msg.blockType === 'text') { + if (!state.replyMessageId) { + const mid = await sendText(chatId, '▍') + if (mid) { + state.replyMessageId = mid + accumulatedText.set(chatId, '') + } + } + } else if (msg.blockType === 'tool_use') { + // Finalize current text before tool calls, + // so text after tools gets a fresh message + await buf.complete() + // If reply still exists (buffer was already empty), clean up directly + if (state.replyMessageId) { + const text = accumulatedText.get(chatId) + if (text?.trim()) { + await patchMessage(state.replyMessageId, text) + } + accumulatedText.delete(chatId) + chatStates.delete(chatId) + buffers.get(chatId)?.reset() } } break @@ -295,16 +316,13 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< } break - case 'tool_use_complete': { - const info = formatToolUse(msg.toolName, msg.input) - await sendText(chatId, info) + case 'tool_use_complete': + // Tool details are noise for IM users; visible in Desktop if needed. break - } case 'tool_result': - if (msg.isError) { - await sendText(chatId, '❌ 工具执行失败') - } + // Tool errors are handled internally by the AI (retries etc.) + // No need to notify the user for every failed attempt. break case 'permission_request': { @@ -416,7 +434,16 @@ async function handleMessage(data: any): Promise { buffers.get(chatId)?.reset() buffers.delete(chatId) pendingProjectSelection.delete(chatId) - await showProjectPicker(chatId) + + const workDir = config.defaultProjectDir + if (workDir) { + const ok = await createSessionForChat(chatId, workDir) + if (ok) { + await sendText(chatId, '✅ 已新建会话,可以开始对话了。') + } + } else { + await showProjectPicker(chatId) + } return } if (text === '/stop' || text === '停止') { @@ -452,7 +479,10 @@ async function handleMessage(data: any): Promise { enqueue(chatId, async () => { const ready = await ensureSession(chatId) if (ready) { - bridge.sendUserMessage(chatId, text!) + const sent = bridge.sendUserMessage(chatId, text!) + if (!sent) { + await sendText(chatId, '⚠️ 消息发送失败,连接可能已断开。请发送 /new 重新开始。') + } } }) } diff --git a/adapters/telegram/index.ts b/adapters/telegram/index.ts index 4d145431..3fc62385 100644 --- a/adapters/telegram/index.ts +++ b/adapters/telegram/index.ts @@ -100,7 +100,7 @@ async function ensureSession(chatId: string): Promise { if (stored) { bridge.connectSession(chatId, stored.sessionId) bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg)) - return true + return await bridge.waitForOpen(chatId) } const workDir = config.defaultProjectDir @@ -119,6 +119,11 @@ async function createSessionForChat(chatId: string, workDir: string): Promise handleServerMessage(chatId, msg)) + const opened = await bridge.waitForOpen(chatId) + if (!opened) { + await bot.api.sendMessage(numericChatId, '⚠️ 连接服务器超时,请重试。') + return false + } return true } catch (err) { await bot.api.sendMessage(numericChatId, @@ -168,10 +173,28 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< break case 'content_start': - if (msg.blockType === 'text' && !placeholders.has(chatId)) { - const sent = await bot.api.sendMessage(numericChatId, '▍') - placeholders.set(chatId, { chatId, messageId: sent.message_id }) - accumulatedText.set(chatId, '') + if (msg.blockType === 'text') { + if (!placeholders.has(chatId)) { + const sent = await bot.api.sendMessage(numericChatId, '▍') + placeholders.set(chatId, { chatId, messageId: sent.message_id }) + accumulatedText.set(chatId, '') + } + } else if (msg.blockType === 'tool_use') { + // Finalize current text placeholder before tool calls, + // so text after tools gets a fresh message + await buf.complete() + // If placeholder still exists (buffer was already empty), clean up directly + if (placeholders.has(chatId)) { + const text = accumulatedText.get(chatId) + if (text?.trim()) { + try { + await bot.api.editMessageText(numericChatId, placeholders.get(chatId)!.messageId, text) + } catch { /* ignore */ } + } + placeholders.delete(chatId) + accumulatedText.delete(chatId) + buffers.get(chatId)?.reset() + } } break @@ -193,16 +216,13 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< } break - case 'tool_use_complete': { - const info = formatToolUse(msg.toolName, msg.input) - await bot.api.sendMessage(numericChatId, info) + case 'tool_use_complete': + // Tool details are noise for IM users; visible in Desktop if needed. break - } case 'tool_result': - if (msg.isError) { - await bot.api.sendMessage(numericChatId, `❌ 工具执行失败`) - } + // Tool errors are handled internally by the AI (retries etc.) + // No need to notify the user for every failed attempt. break case 'permission_request': { @@ -245,7 +265,16 @@ bot.command('new', async (ctx) => { buffers.get(chatId)?.reset() buffers.delete(chatId) pendingProjectSelection.delete(chatId) - await showProjectPicker(chatId) + + const workDir = config.defaultProjectDir + if (workDir) { + const ok = await createSessionForChat(chatId, workDir) + if (ok) { + await bot.api.sendMessage(Number(chatId), '✅ 已新建会话,可以开始对话了。') + } + } else { + await showProjectPicker(chatId) + } }) bot.command('projects', async (ctx) => { @@ -308,7 +337,10 @@ bot.on('message:text', (ctx) => { // Normal message flow const ready = await ensureSession(chatId) if (ready) { - bridge.sendUserMessage(chatId, text) + const sent = bridge.sendUserMessage(chatId, text) + if (!sent) { + await bot.api.sendMessage(Number(chatId), '⚠️ 消息发送失败,连接可能已断开。请发送 /new 重新开始。') + } } }) }) diff --git a/src/server/api/sessions.ts b/src/server/api/sessions.ts index c44c3d1c..dad71ff5 100644 --- a/src/server/api/sessions.ts +++ b/src/server/api/sessions.ts @@ -248,13 +248,23 @@ async function getRecentProjects(): Promise { const { sessions } = await sessionService.listSessions({ limit: 200 }) const validSessions = sessions.filter((session) => session.workDirExists && session.workDir) - // Group sessions by projectPath and find most recent per project - const projectMap = new Map() + // First pass: resolve realPath for each session and group by realPath to dedup + const realPathMap = new Map() for (const s of validSessions) { - const existing = projectMap.get(s.projectPath) + // Resolve the real path for dedup + let realPath: string + try { + const workDir = await sessionService.getSessionWorkDir(s.id) + realPath = workDir || sessionService.desanitizePath(s.projectPath) + } catch { + realPath = sessionService.desanitizePath(s.projectPath) + } + + const existing = realPathMap.get(realPath) if (!existing || s.modifiedAt > existing.modifiedAt) { - projectMap.set(s.projectPath, { - modifiedAt: existing ? s.modifiedAt : s.modifiedAt, + realPathMap.set(realPath, { + projectPath: s.projectPath, + modifiedAt: s.modifiedAt, sessionCount: (existing?.sessionCount ?? 0) + 1, sessionId: s.id, }) @@ -263,7 +273,7 @@ async function getRecentProjects(): Promise { } } - // Build project list with real paths + // Build project list with git info const projects: Array<{ projectPath: string realPath: string @@ -275,17 +285,8 @@ async function getRecentProjects(): Promise { sessionCount: number }> = [] - for (const [projectPath, info] of projectMap) { - // Try to get real workDir from the most recent session - let realPath: string - try { - const workDir = await sessionService.getSessionWorkDir(info.sessionId) - realPath = workDir || sessionService.desanitizePath(projectPath) - } catch { - realPath = sessionService.desanitizePath(projectPath) - } - - const projectName = realPath.split('/').filter(Boolean).pop() || projectPath + for (const [realPath, info] of realPathMap) { + const projectName = realPath.split('/').filter(Boolean).pop() || info.projectPath // Check if it's a git repo let isGit = false @@ -316,7 +317,7 @@ async function getRecentProjects(): Promise { } catch { /* not a git repo or dir doesn't exist */ } projects.push({ - projectPath, realPath, projectName, isGit, repoName, branch, + projectPath: info.projectPath, realPath, projectName, isGit, repoName, branch, modifiedAt: info.modifiedAt, sessionCount: info.sessionCount, }) }