✨ 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

@@ -1,7 +1,7 @@
import type { RewriteRequest } from '../qa/codeQa'
import type { ExtensionToWebview, WebviewToExtension } from './messages'
import { describe, expect, it, vi } from 'vitest'
import { window } from 'vscode'
import { askCommentBody } from '../qa/claudeAsk'
import { workspace } from 'vscode'
import { PreviewEditorProvider } from './previewEditor'
vi.mock('vscode', () => ({
@@ -10,27 +10,42 @@ vi.mock('vscode', () => ({
workspace: {
onDidChangeTextDocument: () => ({ dispose: () => {} }),
onDidChangeConfiguration: () => ({ dispose: () => {} }),
openTextDocument: vi.fn(),
applyEdit: vi.fn(async () => true),
},
/** 记下换了哪儿、换成什么 */
WorkspaceEdit: class {
readonly replaced: { range: unknown, text: string }[] = []
replace(_uri: unknown, range: unknown, text: string) { this.replaced.push({ range, text }) }
},
Range: class {
constructor(readonly startLine: number, readonly startCharacter: number, readonly endLine: number, readonly endCharacter: number) {}
},
EndOfLine: { LF: 1, CRLF: 2 },
}))
vi.mock('../../logging/logger', () => ({ logger: { add: vi.fn() } }))
vi.mock('../../webview/html', () => ({ webviewHtml: () => '', webviewUiRoot: () => ({}) }))
vi.mock('../folderStore', () => ({ locate: () => undefined }))
vi.mock('../folderStore', () => ({ locate: () => ({ folder: { uri: { fsPath: '/workspace' } }, key: 'web/a.ts' }) }))
vi.mock('./pathPreview', () => ({ pathPreviewOf: () => undefined }))
vi.mock('./occurrences', () => ({ occurrencesOf: vi.fn() }))
vi.mock('../qa/claudeAsk', () => ({ askCommentBody: vi.fn(), askFailure: (error: Error) => error.message }))
function preview(current: () => Promise<never>) {
const SOURCE = ['// 旧说明', 'export function load() {}']
function preview(rewrite: (uri: unknown, request: RewriteRequest) => Promise<string | undefined>, dirty = false) {
const subscribe = () => ({ dispose: () => {} })
const receive = vi.fn<(listener: (message: WebviewToExtension) => Promise<void>) => { dispose: () => void }>(subscribe)
const post = vi.fn<(message: ExtensionToWebview) => void>()
const uri = { fsPath: '/workspace/a.ts', scheme: 'file', toString: () => 'file:///workspace/a.ts' }
const uri = { fsPath: '/workspace/web/a.ts', scheme: 'file', toString: () => 'file:///workspace/web/a.ts' }
const document = { uri, isDirty: dirty, lineAt: (line: number) => ({ text: SOURCE[line] }), lineCount: SOURCE.length, eol: 1 }
vi.mocked(workspace.openTextDocument).mockResolvedValue(document as never)
const qa = { imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, onDidRewrite: subscribe, profiles: { subscribe }, rewrite: vi.fn(rewrite) }
const provider = new PreviewEditorProvider(...[
{},
{},
{},
{ subscribe },
async () => [],
{ imageRoot: () => undefined, onDidChange: subscribe, onDidAnswer: subscribe, profiles: { subscribe, current } },
qa,
async () => [],
async () => [],
{ onDidChangeDiffBase: subscribe },
@@ -38,30 +53,74 @@ function preview(current: () => Promise<never>) {
async () => false,
] as unknown as ConstructorParameters<typeof PreviewEditorProvider>)
provider.resolveCustomTextEditor(...[
{ uri, isDirty: false },
document,
{ webview: { onDidReceiveMessage: receive, postMessage: post }, onDidChangeViewState: subscribe, onDidDispose: subscribe },
] as unknown as Parameters<PreviewEditorProvider['resolveCustomTextEditor']>)
const opened = (provider as unknown as { previews: Set<{ sent: unknown, dispose: () => void }> }).previews.values().next().value!
opened.sent = { blocks: [{ anchor: 'block', children: [], commentTarget: {}, startLine: 0, endLine: 1 }] }
return { send: receive.mock.calls[0]![0], post, dispose: () => opened.dispose() }
opened.sent = { blocks: [{ anchor: 'block', children: [], commentTarget: { kind: 'replace', startLine: 0, endLine: 0 }, startLine: 0, endLine: 1 }] }
return { send: receive.mock.calls[0]![0], post, qa, dispose: () => opened.dispose() }
}
describe('重写注释的模型选择失败', () => {
it('模型删除后仍发送失败结束消息和错误提示,不调用模型且允许再次尝试', async () => {
describe('预览里重写注释', () => {
it('点按钮:发技能命令、开新会话,记在这一轮上的就是这句命令;开不了时回一条带 askId 的失败,能再试', async () => {
const error = '文件问答模型不可用:official。请重新选择。'
const current = vi.fn<() => Promise<never>>().mockRejectedValue(new Error(error))
const editor = preview(current)
const editor = preview(async () => error)
try {
await expect(editor.send({ type: 'rewriteComment', anchor: 'block' })).resolves.toBeUndefined()
expect(editor.post).toHaveBeenLastCalledWith({ type: 'rewriteEnd', anchor: 'block', status: 'failed', error })
expect(window.showErrorMessage).toHaveBeenCalledWith(`代码阅读:${error}`)
expect(askCommentBody).not.toHaveBeenCalled()
await editor.send({ type: 'rewriteComment', anchor: 'block' })
expect(current).toHaveBeenCalledTimes(2)
expect(editor.post.mock.calls.filter(([message]) => message.type === 'rewriteEnd')).toHaveLength(2)
await editor.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', images: [] })
expect(editor.qa.rewrite).toHaveBeenCalledWith(expect.objectContaining({ fsPath: '/workspace/web/a.ts' }), expect.objectContaining({
askId: 'r1',
anchor: 'block',
question: '/ecc:writing-code-comments web/a.ts:1-2',
prompt: '/ecc:writing-code-comments web/a.ts:1-2',
images: [],
}))
expect(editor.qa.rewrite.mock.calls[0][1]).not.toHaveProperty('followUp')
expect(editor.post).toHaveBeenLastCalledWith({ type: 'rewriteEnd', askId: 'r1', status: 'failed', error })
await editor.send({ type: 'rewriteComment', askId: 'r2', anchor: 'block', images: [] })
expect(editor.qa.rewrite).toHaveBeenCalledTimes(2)
}
finally {
editor.dispose()
}
})
it('输入框里说的话:接着这块的重写会话说,带上块现在在哪;会话找不到时开新会话用的是技能命令加这句话', async () => {
const editor = preview(async () => undefined)
try {
const image = { mediaType: 'image/png', base64: 'iVBORw0KGgo=' }
await editor.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', request: '加上原型图', images: [image] })
expect(editor.qa.rewrite.mock.calls[0][1]).toMatchObject({
question: '加上原型图',
prompt: '/ecc:writing-code-comments web/a.ts:1-2\n\n加上原型图',
followUp: 'web/a.ts:1-2\n\n加上原型图',
images: [image],
})
}
finally {
editor.dispose()
}
})
it('模型答完写回:套回原来的 // 换掉那几行;文件还有没保存的修改时不开始', async () => {
const editor = preview(async () => undefined)
try {
await editor.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', images: [] })
expect(await editor.qa.rewrite.mock.calls[0][1].writeBack('新说明')).toBeUndefined()
const [edit] = vi.mocked(workspace.applyEdit).mock.calls.at(-1)! as unknown as [{ replaced: { text: string }[] }]
expect(edit.replaced.map(item => item.text)).toEqual(['// 新说明'])
expect(await editor.qa.rewrite.mock.calls[0][1].writeBack(undefined)).toBe('模型没有按要求交回注释正文,没有改文件。')
}
finally {
editor.dispose()
}
const dirty = preview(async () => undefined, true)
try {
await dirty.send({ type: 'rewriteComment', askId: 'r1', anchor: 'block', images: [] })
expect(dirty.qa.rewrite).not.toHaveBeenCalled()
expect(dirty.post).toHaveBeenLastCalledWith({ type: 'rewriteEnd', askId: 'r1', status: 'failed', error: '文件还有没保存的修改,模型读的是磁盘上的文件:先保存再重写注释。' })
}
finally {
dirty.dispose()
}
})
})