import type { runClaudeLines } from '../../cc/spawnClaude' import type { AskDeps, AskRun, CommentAsk, CommentAskDeps } from './claudeAsk' import { describe, expect, it } from 'vitest' import { CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError } from '../../cc/spawnClaude' import { COMMENT_SCHEMA } from '../commentRewrite' import { askArgs, askClaude, askCommentBody, askFailure, commentPluginRoot, pluginRootIn, REWRITE_TIMEOUT_MS, rewriteArgs, rewriteFailure } from './claudeAsk' type RunLinesOptions = Parameters[0] const line = (value: unknown): string => JSON.stringify(value) const init = (session: string): string => line({ type: 'system', subtype: 'init', session_id: session }) const delta = (text: string): string => line({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text } }, parent_tool_use_id: null }) const result = (text: string, session: string): string => line({ type: 'result', subtype: 'success', is_error: false, result: text, session_id: session }) /** 假的 claude:记下每次的参数,按次序吐出事先给的行;给了 error 就在吐完后抛它 */ function fakeClaude(runs: { lines: string[], error?: Error }[]): { deps: AskDeps, calls: RunLinesOptions[] } { const calls: RunLinesOptions[] = [] return { calls, deps: { runLines: async (options) => { const run = runs[calls.length] calls.push(options) run.lines.forEach(options.onLine) if (run.error) throw run.error }, }, } } function request(overrides: Partial = {}): AskRun & { events: unknown[] } { const events: unknown[] = [] return { prompt: '完整的问题', images: [], cwd: '/work', signal: new AbortController().signal, onEvent: event => events.push(event), events, ...overrides } } const PNG = { mediaType: 'image/png', base64: 'iVBORw0KGgo=' } const JPEG = { mediaType: 'image/jpeg', base64: '/9j/4AAQ' } /** 从 stdin 发出去的那条消息 */ function sent(call: RunLinesOptions): { text: string, images: unknown[] } { expect(call.input?.endsWith('\n')).toBe(true) const message = JSON.parse(call.input ?? '') as { type: string, message: { role: string, content: { type: string, text?: string, source?: unknown }[] } } expect(message.type).toBe('user') expect(message.message.role).toBe('user') const [first, ...rest] = message.message.content expect(first.type).toBe('text') return { text: first.text ?? '', images: rest } } describe('命令行参数', () => { it('只读:只给读、搜的工具,别的一律拒、不等人批,不加载 MCP;不带跳过权限;问题从 stdin 按 stream-json 进,输出逐字的 stream-json', () => { const args = askArgs({ profilePath: '/p/offical.json' }) expect(args).toEqual([ '--settings', '/p/offical.json', '--tools', 'Read,Grep,Glob', '--allowedTools', 'Read,Grep,Glob', '--permission-mode', 'dontAsk', '--strict-mcp-config', '-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', ]) expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions|Edit|Write|Bash/) }) it('追问带 --resume,放在 -p 前面;没有 profile 就不带 --settings', () => { const args = askArgs({ resume: 'session-1' }) expect(args).not.toContain('--settings') expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 3)).toEqual(['--resume', 'session-1', '-p']) }) }) describe('问题连同图片从 stdin 发', () => { it('一条用户消息:先是问题的文字,后面按贴的先后跟着图片(base64 和格式原样)', async () => { const { deps, calls } = fakeClaude([{ lines: [result('答案', 's1')] }]) await askClaude(request({ images: [PNG, JPEG] }), deps) expect(sent(calls[0])).toEqual({ text: '完整的问题', images: [ { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'iVBORw0KGgo=' } }, { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: '/9j/4AAQ' } }, ], }) }) it('没贴图:只有问题的文字,照样走 stdin', async () => { const { deps, calls } = fakeClaude([{ lines: [result('答案', 's1')] }]) await askClaude(request(), deps) expect(sent(calls[0])).toEqual({ text: '完整的问题', images: [] }) expect(calls[0].args).not.toContain('完整的问题') }) it('追问、会话找不到重新问:两次都带上图片', async () => { const { deps, calls } = fakeClaude([ { lines: [], error: new ClaudeError('Claude 退出码非零 (1): No conversation found with session ID: s0', 'No conversation found with session ID: s0') }, { lines: [result('重新答', 's2')] }, ]) await askClaude(request({ images: [PNG], followUp: { sessionId: 's0', prompt: '短问题' } }), deps) expect(sent(calls[0])).toMatchObject({ text: '短问题', images: [{ source: { data: PNG.base64 } }] }) expect(sent(calls[1])).toMatchObject({ text: '完整的问题', images: [{ source: { data: PNG.base64 } }] }) }) }) describe('问一次', () => { it('在工作区文件夹里跑、带超时和叫停;过程交给 onEvent,答完返回最终回答和会话 id', async () => { const { deps, calls } = fakeClaude([{ lines: [init('s1'), delta('答'), delta('案'), result('答案', 's1')] }]) const run = request({ profilePath: '/profiles/sonnet.json' }) expect(await askClaude(run, deps)).toEqual({ text: '答案', sessionId: 's1' }) expect(run.events).toEqual([{ kind: 'session', sessionId: 's1' }, { kind: 'text', text: '答' }, { kind: 'text', text: '答案' }]) expect(calls).toHaveLength(1) expect(calls[0]).toMatchObject({ cwd: '/work', signal: run.signal, timeoutMs: 300_000 }) expect(calls[0].args.slice(0, 2)).toEqual(['--settings', '/profiles/sonnet.json']) expect(sent(calls[0]).text).toBe('完整的问题') }) it('选的是「默认」(没给 profile):不带 --settings', async () => { const { deps, calls } = fakeClaude([{ lines: [result('答案', 's1')] }]) await askClaude(request(), deps) expect(calls[0].args).not.toContain('--settings') }) it('追问接着上次的会话问短问题;换了 profile 也接着问,带上这次选的 --settings', async () => { const { deps, calls } = fakeClaude([{ lines: [result('接着答', 's1')] }]) expect(await askClaude(request({ profilePath: '/profiles/opus.json', followUp: { sessionId: 's1', prompt: '短问题' } }), deps)).toEqual({ text: '接着答', sessionId: 's1' }) expect(calls[0].args.slice(0, 2)).toEqual(['--settings', '/profiles/opus.json']) expect(calls[0].args.slice(calls[0].args.indexOf('--resume'), calls[0].args.indexOf('--resume') + 2)).toEqual(['--resume', 's1']) expect(sent(calls[0]).text).toBe('短问题') }) it('上次的会话找不到了:按完整问题重新开一次', async () => { const { deps, calls } = fakeClaude([ { lines: [], error: new ClaudeError('Claude 退出码非零 (1): No conversation found with session ID: s0', 'No conversation found with session ID: s0') }, { lines: [init('s2'), result('重新答', 's2')] }, ]) expect(await askClaude(request({ profilePath: '/profiles/opus.json', followUp: { sessionId: 's0', prompt: '短问题' } }), deps)).toEqual({ text: '重新答', sessionId: 's2' }) expect(calls).toHaveLength(2) expect(calls[1].args).not.toContain('--resume') expect(calls[1].args.slice(0, 2)).toEqual(['--settings', '/profiles/opus.json']) expect(sent(calls[1]).text).toBe('完整的问题') }) it('别的失败不重试,照样抛出来;Claude 报的失败、没给回答也算失败', async () => { const timeout = new ClaudeTimeoutError('超时', '') const failing = fakeClaude([{ lines: [], error: timeout }, { lines: [result('不该到这', 's')] }]) await expect(askClaude(request({ followUp: { sessionId: 's0', prompt: '短问题' } }), failing.deps)).rejects.toBe(timeout) expect(failing.calls).toHaveLength(1) await expect(askClaude(request(), fakeClaude([{ lines: [line({ type: 'result', is_error: true, result: 'Invalid API key · Please run /login' })] }]).deps)) .rejects .toThrow('Invalid API key') await expect(askClaude(request(), fakeClaude([{ lines: [init('s')] }]).deps)).rejects.toThrow('没给出回答') }) }) describe('失败了给人看的话', () => { it('超时、没装、没登录、别的', () => { expect(askFailure(new ClaudeTimeoutError('超时', ''))).toBe('等了 5 分钟还没答完,已经停掉。可以把问题问得具体一点再试。') expect(askFailure(new ClaudeError(CLAUDE_MISSING_MSG, ''))).toContain('没找到 Claude Code') expect(askFailure(new ClaudeError('Claude 退出码非零 (1): Invalid API key · Please run /login', ''))).toMatch(/^Claude Code 没登录或登录过期了:在终端里运行 claude 登录一下再问。/) expect(askFailure(new ClaudeError('Claude 退出码非零 (1): 网络断了', ''))).toBe('Claude Code 没答成:Claude 退出码非零 (1): 网络断了') expect(askFailure(new ClaudeCancelledError(''))).toBe('Claude Code 没答成:Claude 调用已停止') }) }) describe('重写注释的命令行参数', () => { const SCRATCH = '/private/var/folders/T/spx-comment-s1' const allowed = 'Read,Grep,Glob,Bash(/Applications/OpenPencil.app/Contents/MacOS/op *),Bash(mkdir -p */prototypes),Edit(//private/var/folders/T/spx-comment-s1/**)' it('不读用户、项目的设置(--restricted),写注释的插件单独加载、能读;只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,别的一律拒、不加载 MCP;按 JSON Schema 交结果', () => { const args = rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: SCRATCH, profilePath: '/p/sonnet.json', session: { start: 's1' } }) expect(args).toEqual([ '--settings', '/p/sonnet.json', '--restricted', '--plugin-dir', '/plugins/ecc', '--add-dir', '/plugins/ecc', '--add-dir', SCRATCH, '--tools', 'Read,Grep,Glob,Bash,Edit,Write', '--allowedTools', allowed, '--permission-mode', 'dontAsk', '--strict-mcp-config', '--session-id', 's1', '-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose', '--include-partial-messages', '--json-schema', COMMENT_SCHEMA, ]) expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions/) expect(JSON.parse(COMMENT_SCHEMA)).toEqual({ type: 'object', properties: { body: { type: 'string' } }, required: ['body'], additionalProperties: false }) }) it('接着说时 --resume 那次会话、不带 --session-id;没有 profile 就不带 --settings', () => { const args = rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: SCRATCH, session: { resume: 's1' } }) expect(args[0]).toBe('--restricted') expect(args).not.toContain('--session-id') expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 3)).toEqual(['--resume', 's1', '-p']) }) }) describe('写注释的插件在哪', () => { const list = JSON.stringify([ { id: 'claude-mem@thedotmack', enabled: true, installPath: '/plugins/mem' }, { id: 'ecc@ecc', version: '2.2.52', enabled: true, installPath: '/plugins/ecc/2.2.52' }, ]) it('claude plugin list --json 里启用着的 ecc 的目录;没装、没启用、读不出来都没有', () => { expect(pluginRootIn(list, 'ecc')).toBe('/plugins/ecc/2.2.52') expect(pluginRootIn(JSON.stringify([{ id: 'ecc@ecc', enabled: false, installPath: '/plugins/ecc' }]), 'ecc')).toBeUndefined() expect(pluginRootIn(JSON.stringify([{ id: 'ecc-tools@x', enabled: true, installPath: '/plugins/tools' }]), 'ecc')).toBeUndefined() expect(pluginRootIn('not json', 'ecc')).toBeUndefined() }) it('在这次的目录里问 claude(项目里装的插件也算);找不到时说清楚要先装上', async () => { const calls: RunLinesOptions[] = [] const found = await commentPluginRoot('/work', async (options) => { calls.push(options) JSON.stringify(JSON.parse(list), null, 2).split('\n').forEach(options.onLine) }) expect(found).toBe('/plugins/ecc/2.2.52') expect(calls[0]).toMatchObject({ args: ['plugin', 'list', '--json'], cwd: '/work' }) await expect(commentPluginRoot('/work', async options => options.onLine('[]'))).rejects.toThrow('没找到启用着的 ecc 插件') }) }) describe('重写注释问一次', () => { const structured = (body: string, session = 's1'): string => line({ type: 'result', subtype: 'success', is_error: false, result: JSON.stringify({ body }), structured_output: { body }, session_id: session }) /** 假的 claude 再加上找插件、建草稿目录、起会话 id:都记下来 */ function commentDeps(runs: { lines: string[], error?: Error }[]) { const { deps, calls } = fakeClaude(runs) const scratches: string[] = [] const lookups: string[] = [] let next = 0 const commentDeps: CommentAskDeps = { ...deps, pluginRoot: async (cwd) => { lookups.push(cwd) return '/plugins/ecc' }, scratch: async (sessionId) => { scratches.push(sessionId) return `/tmp/spx-comment-${sessionId}` }, newSessionId: () => `new-${++next}`, } return { deps: commentDeps, calls, scratches, lookups } } function comment(overrides: Partial = {}): CommentAsk & { events: unknown[] } { const events: unknown[] = [] return { prompt: '/ecc:writing-code-comments src/a.py:3-8', images: [], cwd: '/work', signal: new AbortController().signal, onEvent: event => events.push(event), events, ...overrides } } it('开新会话:自己起会话 id、按它建草稿目录,带上插件目录和超时;stdin 里是那句提示和贴的图;返回正文和会话 id', async () => { const { deps, calls, scratches, lookups } = commentDeps([{ lines: [structured('按名字找文件。', 'new-1')] }]) const ask = comment({ profilePath: '/p/sonnet.json', images: [PNG] }) expect(await askCommentBody(ask, deps)).toEqual({ body: '按名字找文件。', sessionId: 'new-1' }) expect(lookups).toEqual(['/work']) expect(scratches).toEqual(['new-1']) expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: '/tmp/spx-comment-new-1', profilePath: '/p/sonnet.json', session: { start: 'new-1' } })) expect(calls[0]).toMatchObject({ cwd: '/work', signal: ask.signal, timeoutMs: REWRITE_TIMEOUT_MS }) expect(sent(calls[0])).toMatchObject({ text: '/ecc:writing-code-comments src/a.py:3-8', images: [{ source: { data: PNG.base64 } }] }) }) it('接着说:--resume 这块最近一次重写的会话,用那个会话的草稿目录,发块现在在哪和用户的话', async () => { const { deps, calls, scratches } = commentDeps([{ lines: [structured('短一点。', 's0')] }]) expect(await askCommentBody(comment({ followUp: { sessionId: 's0', prompt: 'src/a.py:3-10\n\n短一点' } }), deps)).toEqual({ body: '短一点。', sessionId: 's0' }) expect(scratches).toEqual(['s0']) expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', scratch: '/tmp/spx-comment-s0', session: { resume: 's0' } })) expect(sent(calls[0]).text).toBe('src/a.py:3-10\n\n短一点') }) it('那次会话找不到了:开新会话按技能命令重新来,图片照样带上', async () => { const { deps, calls, scratches } = commentDeps([ { lines: [line({ type: 'result', subtype: 'error_during_execution', is_error: true, errors: ['No conversation found with session ID: s0'], session_id: 's0' })] }, { lines: [structured('重新写的。', 'new-1')] }, ]) const ask = comment({ prompt: '/ecc:writing-code-comments src/a.py:3-10\n\n短一点', images: [PNG], followUp: { sessionId: 's0', prompt: 'src/a.py:3-10\n\n短一点' } }) expect(await askCommentBody(ask, deps)).toEqual({ body: '重新写的。', sessionId: 'new-1' }) expect(scratches).toEqual(['s0', 'new-1']) expect(calls[1].args).toContain('--session-id') expect(calls[1].args).not.toContain('--resume') expect(sent(calls[1])).toMatchObject({ text: '/ecc:writing-code-comments src/a.py:3-10\n\n短一点', images: [{ source: { data: PNG.base64 } }] }) }) it('只回了一段话、没交结构化结果:只有会话 id 没有正文;Claude Code 报错、没给结果:抛错,别的失败不重开', async () => { expect(await askCommentBody(comment(), commentDeps([{ lines: [result('我改好了。', 'new-1')] }]).deps)).toEqual({ sessionId: 'new-1' }) await expect(askCommentBody(comment(), commentDeps([{ lines: [line({ type: 'result', is_error: true, result: '额度用完了' })] }]).deps)).rejects.toThrow('额度用完了') await expect(askCommentBody(comment(), commentDeps([{ lines: [] }]).deps)).rejects.toThrow('没给出结果') const timeout = commentDeps([{ lines: [], error: new ClaudeTimeoutError('超时', '') }, { lines: [structured('不该到这')] }]) await expect(askCommentBody(comment({ followUp: { sessionId: 's0', prompt: 'p' } }), timeout.deps)).rejects.toBeInstanceOf(ClaudeTimeoutError) expect(timeout.calls).toHaveLength(1) }) it('等太久停掉时说的是没写完', () => { expect(rewriteFailure(new ClaudeTimeoutError('超时', ''))).toBe(`等了 ${REWRITE_TIMEOUT_MS / 60_000} 分钟还没写完,已经停掉。`) expect(rewriteFailure(new ClaudeError('Claude 退出码非零 (1): 网络断了', ''))).toBe('Claude Code 没答成:Claude 退出码非零 (1): 网络断了') }) })