每块的重写记录存进 .spx/comment-rewrites.json(和问答分开,改名、删文件跟着走),块下面同问答区:灰字历史、当前这一轮、输入框;输入框里的话接着这块最近一次重写的会话说,交回新正文照样写回,写回失败也记下原因。 重写跑在 claude --restricted 下:只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,写注释的插件单独加载;超时 15 分钟。 进行中的、出错被叫停留着的那一轮记在扩展端,切走标签页再切回来照样接上(问 AI 同一套)。
320 lines
18 KiB
TypeScript
320 lines
18 KiB
TypeScript
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<typeof runClaudeLines>[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> = {}): 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> = {}): 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): 网络断了')
|
|
})
|
|
})
|