From 4ccda92cfe4b62a50911059e9884fc8ef512edef 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: Sat, 11 Apr 2026 15:24:10 +0800 Subject: [PATCH] chore(adapters): snapshot pre-attachment WIP (permission card v2, handler serialization, markdown style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries forward ~800 lines of in-flight work that existed in the working tree before the IM-attachment branch was cut. Kept as a single isolated commit so later attachment work has a clean diff baseline and so this WIP can be cherry-picked back to main on its own merits. Contents: - ws-bridge: per-chat handler chain serialization; sendPermissionResponse now accepts optional `rule: 'always'` for persistent permits - feishu/index.ts: permission card rewritten to Schema 2.0 with icon, cross-dir warning, and ♾️ 永久允许 button; optimizeMarkdownForFeishu applied to streaming card - feishu/markdown-style.ts: new Feishu-specific markdown downgrader - tests: corresponding bun:test coverage Co-Authored-By: Claude Opus 4.6 (1M context) --- adapters/common/__tests__/ws-bridge.test.ts | 121 +++++ adapters/common/ws-bridge.ts | 52 +- adapters/feishu/__tests__/feishu.test.ts | 450 ++++++++++++++++-- .../feishu/__tests__/markdown-style.test.ts | 192 ++++++++ adapters/feishu/index.ts | 249 +++++++++- adapters/feishu/markdown-style.ts | 71 +++ 6 files changed, 1062 insertions(+), 73 deletions(-) create mode 100644 adapters/feishu/__tests__/markdown-style.test.ts create mode 100644 adapters/feishu/markdown-style.ts diff --git a/adapters/common/__tests__/ws-bridge.test.ts b/adapters/common/__tests__/ws-bridge.test.ts index 7fd85c70..b691335d 100644 --- a/adapters/common/__tests__/ws-bridge.test.ts +++ b/adapters/common/__tests__/ws-bridge.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test' import { WsBridge } from '../ws-bridge.js' +import { WebSocketServer, type WebSocket as WsServerSocket } from 'ws' describe('WsBridge', () => { let bridge: WsBridge @@ -54,3 +55,123 @@ describe('WsBridge', () => { expect(bridge.hasSession('b')).toBe(false) }) }) + +// --------------------------------------------------------------------------- +// Integration: per-chat handler serialization +// +// Reproduces the feishu text→tool→text race: a slow handler on msg 1 must +// complete BEFORE msg 2's handler starts, otherwise msg 2 reads the stale +// state msg 1's continuation is about to clear. +// --------------------------------------------------------------------------- + +describe('WsBridge: handler serialization', () => { + let server: WebSocketServer + let port: number + let connections: WsServerSocket[] + let serverUrl: string + + beforeEach(async () => { + connections = [] + // port 0 → let the OS pick a free one + server = new WebSocketServer({ port: 0 }) + server.on('connection', (ws) => { + connections.push(ws) + }) + await new Promise((resolve) => server.on('listening', () => resolve())) + port = (server.address() as { port: number }).port + serverUrl = `ws://127.0.0.1:${port}` + }) + + afterEach(async () => { + // Forcibly kill any server-side sockets (not graceful close) so + // WebSocketServer.close() doesn't wait for client FIN. + for (const ws of connections) { + try { ws.terminate() } catch {} + } + await new Promise((resolve) => { + const t = setTimeout(() => resolve(), 500) // hard cap + server.close(() => { + clearTimeout(t) + resolve() + }) + }) + }) + + it('processes handler calls in strict FIFO order per chatId', async () => { + const bridge = new WsBridge(serverUrl, 'test') + const events: string[] = [] + + // The handler simulates an async side effect that takes varying time. + // If handlers ran concurrently, fast msgs could finish before slow ones, + // producing an out-of-order `events` array. + bridge.onServerMessage('chat-1', async (msg: any) => { + const tag = msg.tag as string + const delay = msg.delay as number + events.push(`start:${tag}`) + await new Promise((r) => setTimeout(r, delay)) + events.push(`end:${tag}`) + }) + + bridge.connectSession('chat-1', 'sess-1') + const ok = await bridge.waitForOpen('chat-1') + expect(ok).toBe(true) + expect(connections.length).toBe(1) + const serverWs = connections[0]! + + // Blast three messages back-to-back. msg1 is slow, msg2/msg3 are fast. + // With serialization: start:1, end:1, start:2, end:2, start:3, end:3 + // Without serialization: start:1, start:2, start:3, end:2, end:3, end:1 + serverWs.send(JSON.stringify({ tag: '1', delay: 40 })) + serverWs.send(JSON.stringify({ tag: '2', delay: 5 })) + serverWs.send(JSON.stringify({ tag: '3', delay: 5 })) + + // Wait long enough for all three handlers to run serially + await new Promise((r) => setTimeout(r, 200)) + + expect(events).toEqual([ + 'start:1', 'end:1', + 'start:2', 'end:2', + 'start:3', 'end:3', + ]) + + bridge.destroy() + }) + + it('handler error does not break the chain (subsequent messages still run)', async () => { + const bridge = new WsBridge(serverUrl, 'test') + const events: string[] = [] + + bridge.onServerMessage('chat-err', async (msg: any) => { + if (msg.throw) { + events.push('throwing') + throw new Error('boom') + } + events.push(`ok:${msg.tag}`) + }) + + bridge.connectSession('chat-err', 'sess-err') + await bridge.waitForOpen('chat-err') + const serverWs = connections[0]! + + serverWs.send(JSON.stringify({ throw: true })) + serverWs.send(JSON.stringify({ tag: 'after' })) + + await new Promise((r) => setTimeout(r, 80)) + + expect(events).toEqual(['throwing', 'ok:after']) + + bridge.destroy() + }) + + it('resetSession clears the handler chain', async () => { + const bridge = new WsBridge(serverUrl, 'test') + bridge.onServerMessage('chat-reset', () => {}) + bridge.connectSession('chat-reset', 'sess-reset') + await bridge.waitForOpen('chat-reset') + + bridge.resetSession('chat-reset') + expect(bridge.hasSession('chat-reset')).toBe(false) + + bridge.destroy() + }) +}) diff --git a/adapters/common/ws-bridge.ts b/adapters/common/ws-bridge.ts index a82da6f9..544abebe 100644 --- a/adapters/common/ws-bridge.ts +++ b/adapters/common/ws-bridge.ts @@ -32,6 +32,10 @@ export class WsBridge { private sessions = new Map() /** Single handler per chatId — separate from sessions so reconnect doesn't duplicate */ private handlers = new Map() + /** Per-chat FIFO queue of in-flight handler promises. + * Ensures an async handler for message N completes before handler for N+1 + * starts, preventing state races at `await` points. */ + private handlerChains = new Map>() private serverUrl: string private platform: string private heartbeatTimer: ReturnType | null = null @@ -58,9 +62,25 @@ export class WsBridge { return this.send(chatId, { type: 'user_message', content }) } - /** Respond to a permission request. */ - sendPermissionResponse(chatId: string, requestId: string, allowed: boolean): boolean { - return this.send(chatId, { type: 'permission_response', requestId, allowed }) + /** Respond to a permission request. + * + * @param rule - optional rule name to make the permission persistent. + * Currently the server supports `'always'`, which uses the CLI's + * permission_suggestions to produce updatedPermissions so the same + * tool call won't prompt again in this session. Omit for one-shot allow. */ + sendPermissionResponse( + chatId: string, + requestId: string, + allowed: boolean, + rule?: string, + ): boolean { + const message: Record = { + type: 'permission_response', + requestId, + allowed, + } + if (rule) message.rule = rule + return this.send(chatId, message) } /** Stop the current generation. */ @@ -82,6 +102,7 @@ export class WsBridge { this.sessions.delete(chatId) } this.handlers.delete(chatId) + this.handlerChains.delete(chatId) } /** Has a session (connected or handler registered) for chatId. */ @@ -102,6 +123,7 @@ export class WsBridge { } this.sessions.clear() this.handlers.clear() + this.handlerChains.clear() } // ------- internal ------- @@ -131,14 +153,30 @@ export class WsBridge { }) ws.on('message', (raw) => { + let msg: ServerMessage try { - const msg: ServerMessage = JSON.parse(raw.toString()) - if (msg.type === 'pong') return - const handler = this.handlers.get(chatId) - if (handler) handler(msg) + msg = JSON.parse(raw.toString()) } catch (err) { console.error('[WsBridge] Parse error:', err) + return } + if (msg.type === 'pong') return + const handler = this.handlers.get(chatId) + if (!handler) return + + // Serialize per-chat handler calls: chain each message onto the previous + // one so a slow handler (e.g. one awaiting im.message.create) fully + // finishes before the next message's handler runs. This prevents state + // races where a later message reads stale map entries set up by an + // earlier-but-still-in-flight handler. + const prev = this.handlerChains.get(chatId) ?? Promise.resolve() + const next = prev + .catch(() => {}) // upstream errors must not poison the chain + .then(() => Promise.resolve().then(() => handler(msg))) + .catch((err) => { + console.error(`[WsBridge] Handler error on ${chatId}:`, err) + }) + this.handlerChains.set(chatId, next) }) ws.on('close', (code, reason) => { diff --git a/adapters/feishu/__tests__/feishu.test.ts b/adapters/feishu/__tests__/feishu.test.ts index 86a09660..cd13e582 100644 --- a/adapters/feishu/__tests__/feishu.test.ts +++ b/adapters/feishu/__tests__/feishu.test.ts @@ -5,6 +5,7 @@ */ import { describe, it, expect } from 'bun:test' +import * as path from 'node:path' // ---------- helpers extracted from feishu/index.ts for testability ---------- @@ -147,40 +148,191 @@ function buildProjectPickerCard(projects: RecentProject[]): Record { - const preview = typeof input === 'string' ? input : JSON.stringify(input, null, 2) - const truncated = preview.length > 300 ? preview.slice(0, 300) + '…' : preview +// ---------- permission card helpers (mirrored from feishu/index.ts) ---------- - return { - schema: '2.0', - config: { wide_screen_mode: true }, - header: { - title: { tag: 'plain_text', content: '🔐 需要权限确认' }, - template: 'orange', +type ToolCallSummary = { + icon: string + label: string + target?: string + filePath?: string +} + +function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary { + const rec: Record = + input && typeof input === 'object' ? (input as Record) : {} + const str = (key: string): string | undefined => + typeof rec[key] === 'string' ? (rec[key] as string) : undefined + + switch (toolName) { + case 'Write': { + const fp = str('file_path') + return { icon: '✏️', label: '写入文件', target: fp, filePath: fp } + } + case 'Edit': + case 'MultiEdit': + case 'NotebookEdit': { + const fp = str('file_path') ?? str('notebook_path') + return { icon: '✏️', label: '修改文件', target: fp, filePath: fp } + } + case 'Read': { + const fp = str('file_path') + return { icon: '📖', label: '读取文件', target: fp, filePath: fp } + } + case 'Bash': + case 'BashOutput': { + return { icon: '🖥️', label: '执行命令', target: str('command') } + } + case 'Grep': { + const pattern = str('pattern') + return { + icon: '🔍', + label: '搜索内容', + target: pattern ? `pattern: ${pattern}` : undefined, + filePath: str('path'), + } + } + case 'Glob': { + const pattern = str('pattern') + return { + icon: '📁', + label: '查找文件', + target: pattern ? `pattern: ${pattern}` : undefined, + filePath: str('path'), + } + } + case 'WebFetch': + return { icon: '🌐', label: '访问网页', target: str('url') } + case 'WebSearch': + return { icon: '🌐', label: '搜索网页', target: str('query') } + default: + return { icon: '🔧', label: toolName } + } +} + +function isOutsideWorkDir(filePath: string, workDir: string): boolean { + const abs = path.isAbsolute(filePath) + ? path.normalize(filePath) + : path.resolve(workDir, filePath) + const normWork = path.normalize(workDir).replace(/\/+$/, '') + return abs !== normWork && !abs.startsWith(normWork + path.sep) +} + +function truncateTarget(s: string, maxLen = 160): string { + if (s.length <= maxLen) return s + return s.slice(0, maxLen - 1) + '…' +} + +function buildPermissionCard( + toolName: string, + input: unknown, + requestId: string, + workDir?: string, +): Record { + const summary = summarizeToolCall(toolName, input) + const crossDir = Boolean( + workDir && summary.filePath && isOutsideWorkDir(summary.filePath, workDir), + ) + + const elements: Record[] = [ + { + tag: 'markdown', + content: `${summary.icon} **${summary.label}** \`${toolName}\``, }, - elements: [ + ] + + if (summary.target) { + const shown = summary.filePath + ? prettyPath(summary.target, 80) + : truncateTarget(summary.target, 160) + elements.push({ + tag: 'markdown', + content: '```\n' + shown + '\n```', + margin: '4px 0 0 0', + }) + } + + if (crossDir) { + elements.push({ + tag: 'markdown', + content: '⚠️ **该操作位于当前项目目录之外**', + margin: '8px 0 0 0', + text_size: 'notation', + }) + } + + elements.push({ tag: 'hr', margin: '12px 0 0 0' }) + + elements.push({ + tag: 'column_set', + flex_mode: 'stretch', + horizontal_spacing: '8px', + margin: '8px 0 0 0', + columns: [ { - tag: 'markdown', - content: `**工具**: ${toolName}\n**内容**:\n\`\`\`\n${truncated}\n\`\`\``, - }, - { - tag: 'action', - actions: [ + tag: 'column', + width: 'weighted', + weight: 1, + vertical_align: 'center', + elements: [ { tag: 'button', text: { tag: 'plain_text', content: '✅ 允许' }, type: 'primary', + size: 'medium', value: { action: 'permit', requestId, allowed: true }, }, + ], + }, + { + tag: 'column', + width: 'weighted', + weight: 1, + vertical_align: 'center', + elements: [ + { + tag: 'button', + text: { tag: 'plain_text', content: '♾️ 永久允许' }, + type: 'default', + size: 'medium', + value: { action: 'permit', requestId, allowed: true, rule: 'always' }, + }, + ], + }, + { + tag: 'column', + width: 'weighted', + weight: 1, + vertical_align: 'center', + elements: [ { tag: 'button', text: { tag: 'plain_text', content: '❌ 拒绝' }, type: 'danger', + size: 'medium', value: { action: 'permit', requestId, allowed: false }, }, ], }, ], + }) + + return { + schema: '2.0', + config: { + wide_screen_mode: false, + update_multi: true, + }, + header: { + title: { tag: 'plain_text', content: '🔐 需要权限确认' }, + subtitle: { + tag: 'plain_text', + content: crossDir ? '⚠️ 跨目录操作' : toolName, + }, + template: crossDir ? 'red' : 'orange', + padding: '12px 12px 12px 12px', + icon: { tag: 'standard_icon', token: 'lock-chat_filled' }, + }, + body: { elements }, } } @@ -278,42 +430,256 @@ describe('Feishu: event parsing', () => { }) describe('Feishu: permission card', () => { - it('builds valid card structure', () => { - const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abcde') + // Helpers to reach into Schema 2.0 body.elements + function getBodyElements(card: Record): any[] { + return ((card.body as any).elements ?? []) as any[] + } + function getActionRow(card: Record): any { + return getBodyElements(card).find((el) => el.tag === 'column_set') + } + function getButtons(card: Record): any[] { + return getActionRow(card).columns.map( + (c: any) => c.elements.find((e: any) => e.tag === 'button'), + ) + } + // ----- Schema 2.0 regression ----- + + it('uses Schema 2.0 with body.elements wrapper (not top-level elements)', () => { + const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc') expect(card.schema).toBe('2.0') - expect((card.header as any).title.content).toContain('权限确认') - expect((card.elements as any[]).length).toBe(2) // markdown + action - - const actionElement = (card.elements as any[])[1] - expect(actionElement.tag).toBe('action') - expect(actionElement.actions.length).toBe(2) // allow + deny buttons + expect(card.elements).toBeUndefined() // old bug had top-level elements + expect((card.body as any).elements).toBeDefined() + expect((card.config as any).update_multi).toBe(true) + expect((card.config as any).wide_screen_mode).toBe(false) // mobile-first }) - it('allow button has correct value', () => { - const card = buildPermissionCard('Read', {}, 'xyz12') - const allowBtn = (card.elements as any[])[1].actions[0] - - expect(allowBtn.value.action).toBe('permit') - expect(allowBtn.value.requestId).toBe('xyz12') - expect(allowBtn.value.allowed).toBe(true) + it('header has title, subtitle, template, icon', () => { + const card = buildPermissionCard('Bash', { command: 'npm test' }, 'abc') + const header = card.header as any + expect(header.title.content).toContain('权限确认') + expect(header.subtitle.content).toBe('Bash') + expect(header.template).toBe('orange') + expect(header.icon.tag).toBe('standard_icon') }) - it('deny button has correct value', () => { - const card = buildPermissionCard('Read', {}, 'xyz12') - const denyBtn = (card.elements as any[])[1].actions[1] + // ----- Three buttons ----- - expect(denyBtn.value.action).toBe('permit') - expect(denyBtn.value.requestId).toBe('xyz12') - expect(denyBtn.value.allowed).toBe(false) + it('has three action buttons in order: 允许 | 永久允许 | 拒绝', () => { + const card = buildPermissionCard('Read', {}, 'xyz') + const [allow, always, deny] = getButtons(card) + expect(allow.text.content).toContain('允许') + expect(allow.type).toBe('primary') + expect(always.text.content).toContain('永久允许') + expect(always.type).toBe('default') + expect(deny.text.content).toContain('拒绝') + expect(deny.type).toBe('danger') }) - it('truncates long input preview', () => { - const longInput = { command: 'x'.repeat(500) } - const card = buildPermissionCard('Bash', longInput, 'abc') - const mdElement = (card.elements as any[])[0] + it('允许 button carries allowed=true and no rule', () => { + const card = buildPermissionCard('Read', {}, 'req-1') + const [allow] = getButtons(card) + expect(allow.value).toEqual({ + action: 'permit', + requestId: 'req-1', + allowed: true, + }) + expect(allow.value.rule).toBeUndefined() + }) - expect(mdElement.content).toContain('…') + it('永久允许 button carries allowed=true + rule=always', () => { + const card = buildPermissionCard('Read', {}, 'req-2') + const always = getButtons(card)[1] + expect(always.value).toEqual({ + action: 'permit', + requestId: 'req-2', + allowed: true, + rule: 'always', + }) + }) + + it('拒绝 button carries allowed=false and no rule', () => { + const card = buildPermissionCard('Read', {}, 'req-3') + const deny = getButtons(card)[2] + expect(deny.value).toEqual({ + action: 'permit', + requestId: 'req-3', + allowed: false, + }) + }) + + // ----- Tool summary rendering ----- + + it('renders Write with ✏️ 写入文件 header and file path target', () => { + const card = buildPermissionCard( + 'Write', + { file_path: '/tmp/output.txt', content: 'hi' }, + 'req', + ) + const elements = getBodyElements(card) + expect(elements[0].content).toContain('✏️') + expect(elements[0].content).toContain('写入文件') + expect(elements[0].content).toContain('`Write`') + // Target rendered as fenced code block + expect(elements[1].content).toContain('/tmp/output.txt') + expect(elements[1].content.startsWith('```')).toBe(true) + }) + + it('renders Edit with ✏️ 修改文件', () => { + const card = buildPermissionCard( + 'Edit', + { file_path: '/a/b.ts', old_string: 'x', new_string: 'y' }, + 'req', + ) + expect(getBodyElements(card)[0].content).toContain('修改文件') + }) + + it('renders Bash with 🖥️ 执行命令 and command target', () => { + const card = buildPermissionCard( + 'Bash', + { command: 'rm -rf /tmp/x' }, + 'req', + ) + const elements = getBodyElements(card) + expect(elements[0].content).toContain('🖥️') + expect(elements[0].content).toContain('执行命令') + expect(elements[1].content).toContain('rm -rf /tmp/x') + }) + + it('truncates very long Bash commands to 160 chars', () => { + const longCmd = 'echo ' + 'x'.repeat(500) + const card = buildPermissionCard('Bash', { command: longCmd }, 'req') + const targetEl = getBodyElements(card)[1] + expect(targetEl.content).toContain('…') + // Fenced code wraps ~10 extra chars + expect(targetEl.content.length).toBeLessThanOrEqual(180) + }) + + it('renders Grep with 🔍 搜索内容 and pattern target', () => { + const card = buildPermissionCard( + 'Grep', + { pattern: 'TODO', path: '/src' }, + 'req', + ) + const elements = getBodyElements(card) + expect(elements[0].content).toContain('🔍') + expect(elements[1].content).toContain('TODO') + }) + + it('renders WebFetch with 🌐 访问网页 and url target', () => { + const card = buildPermissionCard( + 'WebFetch', + { url: 'https://example.com/api' }, + 'req', + ) + const elements = getBodyElements(card) + expect(elements[0].content).toContain('🌐') + expect(elements[0].content).toContain('访问网页') + expect(elements[1].content).toContain('https://example.com/api') + }) + + it('falls back to 🔧 for unknown tools', () => { + const card = buildPermissionCard('CustomTool', { foo: 'bar' }, 'req') + expect(getBodyElements(card)[0].content).toContain('🔧') + expect(getBodyElements(card)[0].content).toContain('CustomTool') + }) + + it('has no target line when input is empty', () => { + const card = buildPermissionCard('Bash', {}, 'req') + const elements = getBodyElements(card) + // elements: [header_md, hr, action_column_set] + expect(elements[1].tag).toBe('hr') + }) + + // ----- Cross-directory detection ----- + + it('does NOT show cross-dir warning when file is inside workDir', () => { + const card = buildPermissionCard( + 'Write', + { file_path: '/Users/me/proj/src/a.ts' }, + 'req', + '/Users/me/proj', + ) + const elements = getBodyElements(card) + const hasWarn = elements.some( + (el) => typeof el.content === 'string' && el.content.includes('项目目录之外'), + ) + expect(hasWarn).toBe(false) + expect((card.header as any).template).toBe('orange') + expect((card.header as any).subtitle.content).toBe('Write') + }) + + it('DOES show cross-dir warning when file is outside workDir (red template)', () => { + const card = buildPermissionCard( + 'Write', + { file_path: '/tmp/evil.sh' }, + 'req', + '/Users/me/proj', + ) + const elements = getBodyElements(card) + const warn = elements.find( + (el) => typeof el.content === 'string' && el.content.includes('项目目录之外'), + ) + expect(warn).toBeDefined() + expect((card.header as any).template).toBe('red') + expect((card.header as any).subtitle.content).toContain('跨目录') + }) + + it('does NOT check cross-dir for Bash (no filePath)', () => { + const card = buildPermissionCard( + 'Bash', + { command: 'rm -rf /tmp/x' }, + 'req', + '/Users/me/proj', + ) + expect((card.header as any).template).toBe('orange') + }) + + it('does not warn when workDir is not provided', () => { + const card = buildPermissionCard( + 'Write', + { file_path: '/tmp/x.ts' }, + 'req', + // workDir omitted + ) + const elements = getBodyElements(card) + const hasWarn = elements.some( + (el) => typeof el.content === 'string' && el.content.includes('项目目录之外'), + ) + expect(hasWarn).toBe(false) + }) +}) + +describe('Feishu: isOutsideWorkDir', () => { + it('returns false for file inside workDir', () => { + expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj')).toBe(false) + }) + + it('returns false for file directly in workDir', () => { + expect(isOutsideWorkDir('/Users/me/proj/a.ts', '/Users/me/proj')).toBe(false) + }) + + it('returns true for file in a sibling directory', () => { + expect(isOutsideWorkDir('/Users/me/other/a.ts', '/Users/me/proj')).toBe(true) + }) + + it('returns true for /tmp file', () => { + expect(isOutsideWorkDir('/tmp/evil.sh', '/Users/me/proj')).toBe(true) + }) + + it('handles workDir with trailing slash', () => { + expect(isOutsideWorkDir('/Users/me/proj/src/a.ts', '/Users/me/proj/')).toBe(false) + }) + + it('resolves relative paths against workDir', () => { + expect(isOutsideWorkDir('src/a.ts', '/Users/me/proj')).toBe(false) + expect(isOutsideWorkDir('../other/a.ts', '/Users/me/proj')).toBe(true) + }) + + it('does not match prefix collisions (proj vs proj2)', () => { + // /Users/me/proj2/a.ts starts with "/Users/me/proj" as a string + // but is NOT inside /Users/me/proj + expect(isOutsideWorkDir('/Users/me/proj2/a.ts', '/Users/me/proj')).toBe(true) }) }) diff --git a/adapters/feishu/__tests__/markdown-style.test.ts b/adapters/feishu/__tests__/markdown-style.test.ts new file mode 100644 index 00000000..0b4b98cc --- /dev/null +++ b/adapters/feishu/__tests__/markdown-style.test.ts @@ -0,0 +1,192 @@ +/** + * optimizeMarkdownForFeishu 单元测试 + * + * 覆盖: + * - H1~H3 降级 (workaround Feishu tag:'markdown' H1~H3 渲染异常) + * - 代码块保护(代码块内的 # 不能被降级) + * - 空行压缩 + * - 边界场景: 无标题不动、全代码块、H4+ 不触发降级、混合内容 + */ + +import { describe, it, expect } from 'bun:test' +import { optimizeMarkdownForFeishu } from '../markdown-style.js' + +describe('optimizeMarkdownForFeishu: 标题降级', () => { + it('H1 → H4', () => { + const out = optimizeMarkdownForFeishu('# Title') + expect(out).toBe('#### Title') + }) + + it('H2 → H5', () => { + const out = optimizeMarkdownForFeishu('## Title') + expect(out).toBe('##### Title') + }) + + it('H3 → H5', () => { + const out = optimizeMarkdownForFeishu('### Title') + expect(out).toBe('##### Title') + }) + + it('混合 H1+H2+H3 全部降级', () => { + const input = '# H1\n## H2\n### H3' + const out = optimizeMarkdownForFeishu(input) + expect(out).toBe('#### H1\n##### H2\n##### H3') + }) + + it('已存在的 H4 在没有 H1~H3 时原样保留(不触发降级管道)', () => { + // 触发条件是原文必须有 H1~H3。纯 H4 文档原样返回。 + const input = '#### Already H4' + const out = optimizeMarkdownForFeishu(input) + expect(out).toBe('#### Already H4') + }) + + it('H5 在没有 H1~H3 时保留', () => { + const input = '##### Already H5' + const out = optimizeMarkdownForFeishu(input) + expect(out).toBe('##### Already H5') + }) + + it('同时存在 H1 和 H4: H1→H4,原 H4 被规则降级为 H5', () => { + // H4~H6 也走 #{2,6} → H5 的管道(因为原文有 H1) + const input = '# Top\n#### Sub' + const out = optimizeMarkdownForFeishu(input) + expect(out).toBe('#### Top\n##### Sub') + }) + + it('标题文本前必须有空格才算标题(#xxx 不是标题)', () => { + const input = '#notaheading' + const out = optimizeMarkdownForFeishu(input) + expect(out).toBe('#notaheading') + }) + + it('降级顺序: 不会把 # 先降成 ####,然后再被 #{2,6} 吃成 #####', () => { + // 这是 openclaw-lark 源码里的关键注释 —— 顺序不能颠倒 + const out = optimizeMarkdownForFeishu('# Top') + expect(out).toBe('#### Top') // 必须恰好是 4 个 #,不是 5 个 + }) + + it('无标题文本原样返回', () => { + const input = 'just some plain text\nwith a newline' + const out = optimizeMarkdownForFeishu(input) + expect(out).toBe(input) + }) +}) + +describe('optimizeMarkdownForFeishu: 代码块保护', () => { + it('代码块内的 # 不会被降级', () => { + const input = '```\n# not a heading\n## also not\n```' + const out = optimizeMarkdownForFeishu(input) + expect(out).toBe(input) + }) + + it('有外部标题时,代码块内的 # 仍受保护', () => { + const input = '# Real heading\n\n```\n# inside code\n```' + const out = optimizeMarkdownForFeishu(input) + expect(out).toBe('#### Real heading\n\n```\n# inside code\n```') + }) + + it('语言标记的 fenced 代码块也受保护', () => { + const input = '## Section\n\n```python\n# python comment\n### not a heading\n```' + const out = optimizeMarkdownForFeishu(input) + expect(out).toBe('##### Section\n\n```python\n# python comment\n### not a heading\n```') + }) + + it('多个代码块按顺序保护与还原', () => { + const input = '# A\n```\n# b1\n```\n## C\n```\n### b2\n```' + const out = optimizeMarkdownForFeishu(input) + expect(out).toBe('#### A\n```\n# b1\n```\n##### C\n```\n### b2\n```') + }) + + it('全文只有代码块时原样返回', () => { + const input = '```\n# in code\n## also in code\n```' + const out = optimizeMarkdownForFeishu(input) + expect(out).toBe(input) + }) +}) + +describe('optimizeMarkdownForFeishu: 空行压缩', () => { + it('3 个换行 → 2 个', () => { + const input = 'line1\n\n\nline2' + const out = optimizeMarkdownForFeishu(input) + expect(out).toBe('line1\n\nline2') + }) + + it('5 个换行 → 2 个', () => { + const input = 'line1\n\n\n\n\nline2' + const out = optimizeMarkdownForFeishu(input) + expect(out).toBe('line1\n\nline2') + }) + + it('2 个换行保留', () => { + const input = 'line1\n\nline2' + const out = optimizeMarkdownForFeishu(input) + expect(out).toBe('line1\n\nline2') + }) + + it('空行压缩不影响代码块内的连续换行', () => { + // 代码块内的换行应当完全保留 + const input = '# Title\n\n```\nline1\n\n\nline2\n```' + const out = optimizeMarkdownForFeishu(input) + expect(out).toBe('#### Title\n\n```\nline1\n\n\nline2\n```') + }) +}) + +describe('optimizeMarkdownForFeishu: 真实场景', () => { + it('复现 screenshot 里的项目结构报告场景', () => { + // 用户 screenshot 里的真实 markdown 片段 + const input = `## OpenCutSkill 项目架构概览 + +### 1. 项目定位 + +Screen Studio 视频自动剪辑工具。 + +### 2. 模块结构 + +\`\`\` +opencutskill/ +├── cli/ +├── core/ +└── tests/ +\`\`\`` + + const out = optimizeMarkdownForFeishu(input) + + // 所有 H2~H3 应该被降级为 H5 + expect(out).toContain('##### OpenCutSkill 项目架构概览') + expect(out).toContain('##### 1. 项目定位') + expect(out).toContain('##### 2. 模块结构') + // 代码块内容原封不动 + expect(out).toContain('opencutskill/') + expect(out).toContain('├── cli/') + // 代码块围栏也原样 + expect(out).toContain('```\nopencutskill/') + // 原始的 ## 字面量不应残留 + expect(out).not.toMatch(/^## OpenCutSkill/m) + expect(out).not.toMatch(/^### 1\./m) + }) + + it('错误输入 fallback 到原文不抛错', () => { + // 即使输入是奇怪的字符串也不应抛错 + const weird = '\u0000\uFFFF```unclosed' + expect(() => optimizeMarkdownForFeishu(weird)).not.toThrow() + }) + + it('空字符串返回空字符串', () => { + expect(optimizeMarkdownForFeishu('')).toBe('') + }) +}) + +describe('optimizeMarkdownForFeishu: cardVersion 参数', () => { + it('cardVersion=1 (默认) 不加段落间距
', () => { + const input = '# A\n## B' + const out = optimizeMarkdownForFeishu(input) + expect(out).not.toContain('
') + }) + + it('cardVersion=2 在连续降级标题之间加
', () => { + const input = '# A\n# B' + const out = optimizeMarkdownForFeishu(input, 2) + // 两个 H1 都降级为 H4,之间插入
+ expect(out).toContain('#### A\n
\n#### B') + }) +}) diff --git a/adapters/feishu/index.ts b/adapters/feishu/index.ts index 5328f579..3b0ec41a 100644 --- a/adapters/feishu/index.ts +++ b/adapters/feishu/index.ts @@ -18,11 +18,11 @@ import { formatImHelp, formatImStatus, splitMessage, - truncateInput, } from '../common/format.js' import { SessionStore } from '../common/session-store.js' import { AdapterHttpClient, type RecentProject } from '../common/http-client.js' import { isAllowedUser, tryPair } from '../common/pairing.js' +import { optimizeMarkdownForFeishu } from './markdown-style.js' // ---------- init ---------- @@ -221,15 +221,22 @@ async function sendCard(chatId: string, card: Record): Promise< } } -/** Build a simple streaming text card (Schema 1.0, patchable via im.message.patch). */ +/** Build a simple streaming text card (Schema 1.0, patchable via im.message.patch). + * + * The `content` is run through `optimizeMarkdownForFeishu` first: Feishu's + * `tag:'markdown'` element has a known rendering bug where H1~H3 headings + * (`#`, `##`, `###`) show up as literal text instead of being styled. + * openclaw-lark works around this in `src/card/markdown-style.ts` by + * downgrading H1→H4 and H2~H6→H5. We do the same here. */ function buildStreamingCard(text: string): Record { + const content = optimizeMarkdownForFeishu(text || ' ') return { config: { wide_screen_mode: true, update_multi: true, }, elements: [ - { tag: 'markdown', content: text || ' ' }, + { tag: 'markdown', content }, ], } } @@ -361,40 +368,217 @@ function buildProjectPickerCard(projects: RecentProject[]): Record { - const truncated = truncateInput(input, 300) +/** Human-readable summary of a tool call for display in the permission card. */ +type ToolCallSummary = { + icon: string + label: string + /** Display string for the operation target (file path or command preview) */ + target?: string + /** Absolute file path for cross-directory detection, when applicable */ + filePath?: string +} - return { - schema: '2.0', - config: { wide_screen_mode: true }, - header: { - title: { tag: 'plain_text', content: '🔐 需要权限确认' }, - template: 'orange', +/** Map a Claude Code tool call to an icon + human-readable Chinese label. + * Unknown tools fall back to the raw tool name with a generic icon. */ +function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary { + const rec: Record = + input && typeof input === 'object' ? (input as Record) : {} + const str = (key: string): string | undefined => + typeof rec[key] === 'string' ? (rec[key] as string) : undefined + + switch (toolName) { + case 'Write': { + const fp = str('file_path') + return { icon: '✏️', label: '写入文件', target: fp, filePath: fp } + } + case 'Edit': + case 'MultiEdit': + case 'NotebookEdit': { + const fp = str('file_path') ?? str('notebook_path') + return { icon: '✏️', label: '修改文件', target: fp, filePath: fp } + } + case 'Read': { + const fp = str('file_path') + return { icon: '📖', label: '读取文件', target: fp, filePath: fp } + } + case 'Bash': + case 'BashOutput': { + return { icon: '🖥️', label: '执行命令', target: str('command') } + } + case 'Grep': { + const pattern = str('pattern') + return { + icon: '🔍', + label: '搜索内容', + target: pattern ? `pattern: ${pattern}` : undefined, + filePath: str('path'), + } + } + case 'Glob': { + const pattern = str('pattern') + return { + icon: '📁', + label: '查找文件', + target: pattern ? `pattern: ${pattern}` : undefined, + filePath: str('path'), + } + } + case 'WebFetch': + return { icon: '🌐', label: '访问网页', target: str('url') } + case 'WebSearch': + return { icon: '🌐', label: '搜索网页', target: str('query') } + default: + return { icon: '🔧', label: toolName } + } +} + +/** True if `filePath` resolves to a location outside of `workDir`. + * Relative paths are resolved against workDir first. */ +function isOutsideWorkDir(filePath: string, workDir: string): boolean { + const abs = path.isAbsolute(filePath) + ? path.normalize(filePath) + : path.resolve(workDir, filePath) + const normWork = path.normalize(workDir).replace(/\/+$/, '') + return abs !== normWork && !abs.startsWith(normWork + path.sep) +} + +/** Truncate a single-line target preview (e.g. shell command) to maxLen. */ +function truncateTarget(s: string, maxLen = 160): string { + if (s.length <= maxLen) return s + return s.slice(0, maxLen - 1) + '…' +} + +/** Build a permission request card (Schema 2.0, mobile-friendly). + * + * Layout: + * header → 🔐 需要权限确认 (orange / red if cross-dir) + * body → **