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) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-09 20:40:07 +08:00
parent 6085447277
commit 824b1967ff
5 changed files with 207 additions and 48 deletions

View File

@ -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<string, unknown>
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, unknown>): 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)

View File

@ -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<boolean> {
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<string, unknown>): 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
}

View File

@ -210,7 +210,7 @@ async function ensureSession(chatId: string): Promise<boolean> {
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<bo
sessionStore.set(chatId, sessionId, workDir)
bridge.connectSession(chatId, sessionId)
bridge.onServerMessage(chatId, (msg) => 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<void> {
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<void> {
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 重新开始。')
}
}
})
}

View File

@ -100,7 +100,7 @@ async function ensureSession(chatId: string): Promise<boolean> {
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<bo
sessionStore.set(chatId, sessionId, workDir)
bridge.connectSession(chatId, sessionId)
bridge.onServerMessage(chatId, (msg) => 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 重新开始。')
}
}
})
})

View File

@ -248,13 +248,23 @@ async function getRecentProjects(): Promise<Response> {
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<string, { modifiedAt: string; sessionCount: number; sessionId: string }>()
// First pass: resolve realPath for each session and group by realPath to dedup
const realPathMap = new Map<string, { projectPath: string; modifiedAt: string; sessionCount: number; sessionId: string }>()
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<Response> {
}
}
// 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<Response> {
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<Response> {
} 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,
})
}