✨ 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

+100 -6
View File
@@ -1,10 +1,12 @@
import type { ExtensionContext, Memento, Uri } from 'vscode'
import type { AskRunner } from './claudeAsk'
import type { AskRunner, CommentAnswer, CommentAsk } from './claudeAsk'
import type { RewriteEvent, RewriteRequest } from './codeQa'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { listClaudeProfiles } from '../../cc/profiles'
import { ClaudeCancelledError } from '../../cc/spawnClaude'
import { getSettings, saveSettings } from '../../settings/store'
import { CodeQa } from './codeQa'
@@ -22,14 +24,21 @@ vi.mock('vscode', async () => {
dispose() { this.emitter.removeAllListeners() }
},
Disposable: { from: () => ({ dispose: () => {} }) },
Uri: {},
Uri: { joinPath: (base: { fsPath: string }, ...parts: string[]) => ({ fsPath: [base.fsPath, ...parts].join('/') }) },
window: { showErrorMessage: vi.fn() },
}
})
vi.mock('../folderStore', () => ({
/** 内存里的一份表:按键取、改了就换 */
FolderStore: class {
get() { return {} }
private map: Record<string, unknown> = {}
readonly onDidChange = () => ({ dispose: () => {} })
get(_folder: unknown, key: string) { return this.map[key] }
async update(_folder: unknown, change: (map: Record<string, unknown>) => Record<string, unknown>) {
this.map = change(this.map)
}
dispose() {}
},
locate: () => ({ folder: { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }, key: 'a.ts' }),
@@ -60,13 +69,13 @@ function context(): ExtensionContext {
} as unknown as ExtensionContext
}
function service(ctx: ExtensionContext) {
function service(ctx: ExtensionContext, rewriter = vi.fn<(ask: CommentAsk) => Promise<CommentAnswer>>()) {
const runner = vi.fn<AskRunner>(run => new Promise((_resolve, reject) => {
run.signal.addEventListener('abort', () => reject(new Error('停止')))
}))
const qa = new CodeQa(ctx, ctx.workspaceState as Memento, runner)
const qa = new CodeQa(ctx, ctx.workspaceState as Memento, runner, rewriter)
services.push(qa)
return { qa, runner }
return { qa, runner, rewriter }
}
beforeEach(() => vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE))
@@ -134,3 +143,88 @@ describe('问这个文件实际使用设置的默认模型', () => {
expect(runner).not.toHaveBeenCalled()
})
})
describe('重写注释的记录', () => {
const SKILL = '/ecc:writing-code-comments a.ts:1-3'
function request(askId: string, overrides: Partial<RewriteRequest> = {}): RewriteRequest {
return { askId, anchor: 'function f', question: SKILL, prompt: SKILL, images: [], writeBack: async () => undefined, ...overrides }
}
/** 等这一轮结束的事件 */
function ended(qa: CodeQa, askId: string): Promise<Extract<RewriteEvent, { kind: 'end' }>> {
return new Promise((resolve) => {
qa.onDidRewrite((event) => {
if (event.kind === 'end' && event.ask.askId === askId)
resolve(event)
})
})
}
const rewritesOf = (qa: CodeQa) => qa.view(uri, file => file.fsPath, 'file').rewrites
it('答完就记在重写记录里、不进问答:写进文件的记正文;接着说时接这块最近一次重写的会话,写回失败也记下原因', async () => {
const ctx = context()
const { qa, runner, rewriter } = service(ctx)
rewriter.mockResolvedValueOnce({ body: '按名字找。', sessionId: 'r1' })
const first = ended(qa, '1')
expect(await qa.rewrite(uri, request('1'))).toBeUndefined()
expect(rewriter.mock.calls[0][0]).toMatchObject({ prompt: SKILL, cwd: '/workspace', images: [] })
expect(rewriter.mock.calls[0][0]).not.toHaveProperty('followUp')
expect((await first).end).toEqual({ status: 'done', body: '按名字找。' })
expect(rewritesOf(qa)).toEqual([{ anchor: 'function f', entries: [expect.objectContaining({ question: SKILL, answer: '按名字找。', id: (await first).entryId })] }])
expect(qa.view(uri, file => file.fsPath, 'file').threads).toEqual([])
rewriter.mockResolvedValueOnce({ body: '短的。', sessionId: 'r1' })
const writeBack = vi.fn(async () => '编辑器没接受这次修改,没有写回。')
const second = ended(qa, '2')
await qa.rewrite(uri, request('2', { question: '短一点', prompt: `${SKILL}\n\n短一点`, followUp: 'a.ts:1-5\n\n短一点', writeBack }))
expect(rewriter.mock.calls[1][0].followUp).toEqual({ sessionId: 'r1', prompt: 'a.ts:1-5\n\n短一点' })
expect((await second).end).toEqual({ status: 'done', body: '短的。', error: '编辑器没接受这次修改,没有写回。' })
expect(writeBack).toHaveBeenCalledWith('短的。')
expect(rewritesOf(qa)[0].entries.map(entry => [entry.question, entry.answer, entry.error])).toEqual([[SKILL, '按名字找。', undefined], ['短一点', '短的。', '编辑器没接受这次修改,没有写回。']])
await qa.ask(uri, { askId: '3', anchor: 'function f', question: '为什么', prompt: '完整的', followUp: '短的', images: [] })
expect(runner.mock.calls[0][0]).not.toHaveProperty('followUp')
})
it('点按钮从头重写(不带 followUp)开新会话;模型没交回正文也记下原因', async () => {
const ctx = context()
const { qa, rewriter } = service(ctx)
rewriter.mockResolvedValueOnce({ body: '旧的。', sessionId: 'r1' })
const first = ended(qa, '1')
await qa.rewrite(uri, request('1'))
await first
rewriter.mockResolvedValueOnce({ sessionId: 'r2' })
const second = ended(qa, '2')
await qa.rewrite(uri, request('2', { writeBack: async body => body === undefined ? '模型没有按要求交回注释正文,没有改文件。' : undefined }))
expect(rewriter.mock.calls[1][0]).not.toHaveProperty('followUp')
expect((await second).end).toEqual({ status: 'done', error: '模型没有按要求交回注释正文,没有改文件。' })
expect(rewritesOf(qa)[0].entries.at(-1)).toMatchObject({ answer: '', error: '模型没有按要求交回注释正文,没有改文件。' })
})
it('出错、被叫停的不记,也不写文件;进行中的、没记下留着的那一轮在 rewriteCurrent 里,页面重新加载能再画出来,收起这块时去掉', async () => {
const ctx = context()
const { qa, rewriter } = service(ctx)
rewriter.mockRejectedValueOnce(new Error('额度用完了'))
const writeBack = vi.fn(async () => undefined)
const failed = ended(qa, '1')
await qa.rewrite(uri, request('1', { writeBack }))
expect((await failed).end).toEqual({ status: 'failed', error: 'Claude Code 没答成:额度用完了' })
expect(qa.view(uri, file => file.fsPath, 'file').rewriteCurrent).toMatchObject([{ askId: '1', status: 'failed', error: 'Claude Code 没答成:额度用完了' }])
rewriter.mockImplementationOnce(ask => new Promise((_resolve, reject) => {
ask.signal.addEventListener('abort', () => reject(new ClaudeCancelledError('')))
}))
const stopped = ended(qa, '2')
await qa.rewrite(uri, request('2', { writeBack }))
expect(qa.view(uri, file => file.fsPath, 'file').rewriteCurrent).toEqual([{ askId: '2', anchor: 'function f', question: SKILL, text: '', status: 'running' }])
qa.stopRewrite('2')
expect((await stopped).end).toEqual({ status: 'stopped' })
expect(writeBack).not.toHaveBeenCalled()
expect(rewritesOf(qa)).toEqual([])
expect(qa.view(uri, file => file.fsPath, 'file').rewriteCurrent).toEqual([{ askId: '2', anchor: 'function f', question: SKILL, text: '', status: 'stopped' }])
expect(qa.view(uri, file => file.fsPath, 'file').current).toEqual([])
qa.forgetRewrite(uri, 'function f')
expect(qa.view(uri, file => file.fsPath, 'file').rewriteCurrent).toEqual([])
})
})