✨ feat(vscode): 重写注释留会话记录、能接着说,允许画原型图

每块的重写记录存进 .spx/comment-rewrites.json(和问答分开,改名、删文件跟着走),块下面同问答区:灰字历史、当前这一轮、输入框;输入框里的话接着这块最近一次重写的会话说,交回新正文照样写回,写回失败也记下原因。
重写跑在 claude --restricted 下:只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,写注释的插件单独加载;超时 15 分钟。
进行中的、出错被叫停留着的那一轮记在扩展端,切走标签页再切回来照样接上(问 AI 同一套)。
This commit is contained in:
cruldra committed 2026-10-12 13:45:37 +11:00
1 parent ae4e2160ef
commit c9f60f5b30
32 files changed
+1654 -860

No files matched your search

+143 -20
View File
@@ -1,9 +1,9 @@
import type { runClaudeLines } from '../../cc/spawnClaude'
import type { AskDeps, AskRun } from './claudeAsk'
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 } from './claudeAsk'
import { askArgs, askClaude, askCommentBody, askFailure, commentPluginRoot, pluginRootIn, REWRITE_TIMEOUT_MS, rewriteArgs, rewriteFailure } from './claudeAsk'
type RunLinesOptions = Parameters<typeof runClaudeLines>[0]
@@ -170,27 +170,150 @@ describe('失败了给人看的话', () => {
})
})
describe('重写注释问一次', () => {
const structured = (body: string): string => line({ type: 'result', subtype: 'success', is_error: false, result: JSON.stringify({ body }), structured_output: { body }, session_id: 's1' })
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('只读、按 JSON Schema 交结构化结果:参数在只读那套后面加 --json-schema,stdin 里只有那一句提示;返回结构化结果里的正文', async () => {
const { deps, calls } = fakeClaude([{ lines: [structured('按名字找文件。')] }])
const events: unknown[] = []
const body = await askCommentBody({ prompt: '/ecc:writing-code-comments src/a.py:3-8', profilePath: '/p/sonnet.json', cwd: '/work', signal: new AbortController().signal, onEvent: event => events.push(event) }, deps)
expect(body).toBe('按名字找文件。')
expect(calls[0].args).toEqual([...askArgs({ profilePath: '/p/sonnet.json' }), '--json-schema', COMMENT_SCHEMA])
expect(calls[0].args).toEqual(expect.arrayContaining(['--tools', 'Read,Grep,Glob', '--permission-mode', 'dontAsk']))
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 })
expect(sent(calls[0])).toEqual({ text: '/ecc:writing-code-comments src/a.py:3-8', images: [] })
expect(calls[0].cwd).toBe('/work')
})
it('只回了一段话、没交结构化结果:没有正文;Claude Code 报错、没给结果:抛错', async () => {
const free = fakeClaude([{ lines: [result('我改好了。', 's1')] }])
expect(await askCommentBody({ prompt: 'p', cwd: '/w', signal: new AbortController().signal, onEvent: () => {} }, free.deps)).toBeUndefined()
const failing = fakeClaude([{ lines: [line({ type: 'result', is_error: true, result: '额度用完了' })] }])
await expect(askCommentBody({ prompt: 'p', cwd: '/w', signal: new AbortController().signal, onEvent: () => {} }, failing.deps)).rejects.toThrow('额度用完了')
const empty = fakeClaude([{ lines: [] }])
await expect(askCommentBody({ prompt: 'p', cwd: '/w', signal: new AbortController().signal, onEvent: () => {} }, empty.deps)).rejects.toThrow('没给出结果')
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): 网络断了')
})
})