✨ feat(vscode): 重写注释留会话记录、能接着说,允许画原型图
每块的重写记录存进 .spx/comment-rewrites.json(和问答分开,改名、删文件跟着走),块下面同问答区:灰字历史、当前这一轮、输入框;输入框里的话接着这块最近一次重写的会话说,交回新正文照样写回,写回失败也记下原因。 重写跑在 claude --restricted 下:只放开读搜、OpenPencil 的 op、建 prototypes 目录、改草稿目录,写注释的插件单独加载;超时 15 分钟。 进行中的、出错被叫停留着的那一轮记在扩展端,切走标签页再切回来照样接上(问 AI 同一套)。
This commit is contained in:
1 parent
ae4e2160ef
commit
c9f60f5b30
32 files changed
+1654
-860
No files matched your search
@@ -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): 网络断了')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user