✨ 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,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([])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user