Files
superwork/vscode/src/codeReader/qa/codeQa.test.ts
T

679 lines
37 KiB
TypeScript

import type { ExtensionContext, Memento, Uri } from 'vscode'
import type { runClaudeLines } from '../../cc/spawnClaude'
import type { AskRunner, CommentAnswer, CommentAsk } from './claudeAsk'
import type { AskEvent, 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, ClaudeError, ClaudeTimeoutError } from '../../cc/spawnClaude'
import { getSettings, saveSettings } from '../../settings/store'
import { askClaude, askCommentBody } from './claudeAsk'
import { CodeQa } from './codeQa'
import { parsePathQa, PATH_QA_STATE_KEY } from './pathQa'
vi.mock('vscode', async () => {
const { EventEmitter } = await import('node:events')
return {
EventEmitter: class {
private readonly emitter = new EventEmitter()
event = (listener: (value: unknown) => void) => {
this.emitter.on('event', listener)
return { dispose: () => this.emitter.off('event', listener) }
}
fire(value: unknown) { this.emitter.emit('event', value) }
dispose() { this.emitter.removeAllListeners() }
},
Disposable: { from: () => ({ dispose: () => {} }) },
Uri: { joinPath: (base: { fsPath: string }, ...parts: string[]) => ({ fsPath: [base.fsPath, ...parts].join('/') }) },
window: { showErrorMessage: vi.fn() },
}
})
/** 工作区只有 /workspace 一个文件夹;/workspace 下面的是项目里的文件,别处的是工作区外的库文件 */
const places = vi.hoisted(() => {
const folder = { uri: { fsPath: '/workspace', toString: () => 'file:///workspace' } }
/** ⌘ 点进去的标准库源码所在的目录 */
const libraryDir = '/home/me/.local/share/uv/python/cpython-3.12/lib/python3.12'
const libraryKey = '~/.local/share/uv/python/cpython-3.12/lib/python3.12/pathlib.py'
const inWorkspace = (uri: { fsPath: string }): boolean => uri.fsPath.startsWith('/workspace/')
/** 同 locateQa:项目里的记在 /workspace、键是相对路径;库文件也记在 /workspace(第一个工作区文件夹),键是 ~/…,发给模型写绝对路径、放行它所在的目录 */
const locateQa = (uri: { fsPath: string }) => inWorkspace(uri)
? { folder, key: 'a.ts', promptPath: 'a.ts' }
: { folder, key: libraryKey, promptPath: uri.fsPath, outsideDir: libraryDir }
/** 每份记录文件(`.spx/code-qa.json` 这些)最近建的那个表,测试直接看里面记了什么 */
const stores = new Map<string, { get: (folder: unknown, key: string) => unknown }>()
return { folder, libraryDir, libraryKey, inWorkspace, locateQa, stores }
})
vi.mock('../folderStore', () => ({
/** 内存里的表,每个工作区文件夹一份:按键取、改了就换 */
FolderStore: class {
private readonly maps = new Map<unknown, Record<string, unknown>>()
readonly onDidChange = () => ({ dispose: () => {} })
constructor(file: { path: string }) {
places.stores.set(file.path, this)
}
get(folder: unknown, key: string) { return this.maps.get(folder)?.[key] }
async update(folder: unknown, change: (map: Record<string, unknown>) => Record<string, unknown>) {
this.maps.set(folder, change(this.maps.get(folder) ?? {}))
}
dispose() {}
},
locate: (uri: { fsPath: string }) => places.inWorkspace(uri) ? { folder: places.folder, key: 'a.ts' } : undefined,
locateQa: places.locateQa,
}))
vi.mock('../editor/pathPreview', () => ({
pathQaLocation: places.locateQa,
topQaLocation: (uri: { fsPath: string }) => {
const at = places.locateQa(uri)
return { folder: at.folder, key: `file:///workspace\n${at.key}` }
},
}))
/** 材料只用位置给的路径,不读盘 */
vi.mock('./pathAskDescribe', () => ({
describePath: async ({ at }: { at: { promptPath: string, outsideDir?: string } }) => ({ kind: 'file', path: at.promptPath, ...(at.outsideDir && { outside: true }), head: { unavailable: '测试' } }),
}))
vi.mock('../../cc/profiles', () => ({
getDefaultProfilePath: () => '/p/offical.json',
getProfilesDir: () => '/p',
listClaudeProfiles: vi.fn(),
}))
const AVAILABLE = ['offical', 'sonnet', 'dsflash'].map(name => ({ name, path: `/p/${name}.json` }))
const uri = { fsPath: '/workspace/a.ts', toString: () => 'file:///workspace/a.ts' } as Uri
const dirs: string[] = []
const services: CodeQa[] = []
function context(): ExtensionContext {
const dir = mkdtempSync(path.join(tmpdir(), 'file-qa-settings-'))
dirs.push(dir)
return {
extensionPath: path.resolve(__dirname, '../../..'),
globalStorageUri: { fsPath: dir },
globalState: { get: (key: string) => key === 'superpowers.codeReader.askProfile' ? 'sonnet' : undefined },
workspaceState: { get: () => undefined, update: vi.fn() },
} as unknown as 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, rewriter)
services.push(qa)
return { qa, runner, rewriter }
}
beforeEach(() => vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE))
afterEach(() => {
services.splice(0).forEach(qa => qa.dispose())
dirs.splice(0).forEach(dir => rmSync(dir, { recursive: true, force: true }))
})
describe('问这个文件实际使用设置的默认模型', () => {
it('新增官方文件和保存无关设置不改变显式空选择,真实默认变更才重置', async () => {
const ctx = context()
await saveSettings(ctx, { defaultAskProfilePath: '/p/sonnet.json' })
vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE.filter(item => item.name !== 'offical'))
const { qa, runner } = service(ctx)
await qa.profiles.ask.select('')
vi.mocked(listClaudeProfiles).mockResolvedValue(AVAILABLE)
const shown = vi.fn()
qa.profiles.ask.subscribe(shown)
await saveSettings(ctx, { webhookPort: 9000 })
await vi.waitFor(() => expect(shown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: '' })))
await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })
expect(runner.mock.calls[0]?.[0]).not.toHaveProperty('profilePath')
expect(getSettings(ctx).defaultAskProfilePath).toBe('/p/sonnet.json')
qa.stop('1')
await saveSettings(ctx, { defaultAskProfilePath: '/p/dsflash.json' })
await vi.waitFor(() => expect(shown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: 'dsflash' })))
await qa.askPath(uri, 'file', { askId: '2', question: '这是什么', images: [] })
expect(runner.mock.calls[1]?.[0].profilePath).toBe('/p/dsflash.json')
})
it('忽略旧选择,临时切换不写默认;重新创建后恢复配置', async () => {
const ctx = context()
await saveSettings(ctx, { defaultAskProfilePath: '/p/dsflash.json' })
const { qa, runner } = service(ctx)
await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })
expect(runner.mock.calls[0]?.[0].profilePath).toBe('/p/dsflash.json')
qa.stop('1')
await qa.profiles.ask.select('sonnet')
await qa.askPath(uri, 'file', { askId: '2', question: '这是什么', images: [] })
expect(runner.mock.calls[1]?.[0].profilePath).toBe('/p/sonnet.json')
expect(getSettings(ctx).defaultAskProfilePath).toBe('/p/dsflash.json')
const restarted = service(ctx)
await restarted.qa.askPath(uri, 'file', { askId: '3', question: '这是什么', images: [] })
expect(restarted.runner.mock.calls[0]?.[0].profilePath).toBe('/p/dsflash.json')
})
it('保存新默认后所有现有预览收到新选择,下一个问题和界面使用同一个模型', async () => {
const ctx = context()
const { qa, runner } = service(ctx)
await qa.profiles.ask.select('sonnet')
const shown = vi.fn()
qa.profiles.ask.subscribe(shown)
await saveSettings(ctx, { defaultAskProfilePath: '/p/dsflash.json' })
await vi.waitFor(() => expect(shown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: 'dsflash' })))
await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })
expect(runner.mock.calls[0]?.[0].profilePath).toBe('/p/dsflash.json')
})
it('已删除配置以现有问答错误返回,不能启动模型;代码块问答同样阻止', async () => {
const ctx = context()
await saveSettings(ctx, { defaultAskProfilePath: '/p/deleted.json' })
const { qa, runner } = service(ctx)
expect(await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })).toMatch(/^问答模型不可用:\/p\/deleted\.json。/)
expect(await qa.ask(uri, { askId: '2', anchor: 'a', question: '问题', prompt: '问题', followUp: '继续', images: [] })).toMatch(/^问答模型不可用:\/p\/deleted\.json。/)
expect(runner).not.toHaveBeenCalled()
})
})
describe('问 AI 和重写注释各用各的 profile', () => {
const SKILL = '/ecc:writing-code-comments a.ts:1-3'
const asking = (askId: string) => ({ askId, anchor: 'function f', question: '为什么', prompt: '完整的', followUp: '短的', images: [] })
const rewriting = (askId: string): RewriteRequest => ({ askId, anchor: 'function f', question: SKILL, prompt: SKILL, images: [], writeBack: async () => undefined })
it('各按设置里自己的默认模型起步:块下面问、顶部问、了解一块代码用问答的,重写注释用重写的', async () => {
const ctx = context()
await saveSettings(ctx, { defaultAskProfilePath: '/p/sonnet.json', defaultRewriteProfilePath: '/p/dsflash.json' })
const { qa, runner, rewriter } = service(ctx)
rewriter.mockResolvedValue({ body: '新的。' })
await qa.ask(uri, asking('1'))
await qa.askPath(uri, 'file', { askId: '2', question: '这是什么', images: [] })
qa.stop('2')
await qa.askTop(uri, { askId: '3', question: '用户登录', images: [] }, { prompt: '找', followUp: '接着找' }, async answer => ({ text: answer.text }))
await qa.rewrite(uri, rewriting('4'))
expect(runner.mock.calls.map(([run]) => run.profilePath)).toEqual(['/p/sonnet.json', '/p/sonnet.json', '/p/sonnet.json'])
expect(rewriter.mock.calls[0]?.[0].profilePath).toBe('/p/dsflash.json')
})
it('都没配时两套都用默认的那个', async () => {
const { qa, runner, rewriter } = service(context())
rewriter.mockResolvedValue({ body: '新的。' })
await qa.ask(uri, asking('1'))
await qa.rewrite(uri, rewriting('2'))
expect(runner.mock.calls[0]?.[0].profilePath).toBe('/p/offical.json')
expect(rewriter.mock.calls[0]?.[0].profilePath).toBe('/p/offical.json')
})
it('临时切换只改自己那一套、只通知自己那一套的下拉,不写进设置', async () => {
const ctx = context()
const { qa, runner, rewriter } = service(ctx)
rewriter.mockResolvedValue({ body: '新的。' })
const askShown = vi.fn()
const rewriteShown = vi.fn()
qa.profiles.ask.subscribe(askShown)
qa.profiles.rewrite.subscribe(rewriteShown)
await qa.profiles.rewrite.select('dsflash')
expect(rewriteShown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: 'dsflash' }))
expect(askShown).not.toHaveBeenCalled()
await qa.profiles.ask.select('sonnet')
expect(askShown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: 'sonnet' }))
expect(rewriteShown).toHaveBeenCalledTimes(1)
await qa.ask(uri, asking('1'))
await qa.rewrite(uri, rewriting('2'))
expect(runner.mock.calls[0]?.[0].profilePath).toBe('/p/sonnet.json')
expect(rewriter.mock.calls[0]?.[0].profilePath).toBe('/p/dsflash.json')
expect(getSettings(ctx)).toMatchObject({ defaultAskProfilePath: '', defaultRewriteProfilePath: '' })
})
it('改了重写注释的默认模型只清掉重写那一套的临时选择,问答那一套的留着', async () => {
const ctx = context()
const { qa } = service(ctx)
await qa.profiles.ask.select('sonnet')
await qa.profiles.rewrite.select('sonnet')
const rewriteShown = vi.fn()
qa.profiles.rewrite.subscribe(rewriteShown)
await saveSettings(ctx, { defaultRewriteProfilePath: '/p/dsflash.json' })
await vi.waitFor(() => expect(rewriteShown).toHaveBeenLastCalledWith(expect.objectContaining({ selected: 'dsflash' })))
expect((await qa.profiles.ask.choice()).selected).toBe('sonnet')
})
it('重写注释用的模型删掉了:报重写注释的错、不启动模型;问答照常', async () => {
const ctx = context()
await saveSettings(ctx, { defaultRewriteProfilePath: '/p/deleted.json' })
const { qa, runner, rewriter } = service(ctx)
expect(await qa.rewrite(uri, rewriting('1'))).toMatch(/^重写注释模型不可用:\/p\/deleted\.json。/)
expect(rewriter).not.toHaveBeenCalled()
expect(await qa.ask(uri, asking('2'))).toBeUndefined()
expect(runner).toHaveBeenCalledTimes(1)
})
})
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.mockImplementationOnce(async (ask) => {
await ask.onSession('r1')
return { body: '按名字找。' }
})
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: '短的。' })
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('短的。')
// 两条可能记在同一毫秒,不比先后
const entries = rewritesOf(qa)[0].entries.map(entry => [entry.question, entry.answer, entry.error])
expect(entries).toHaveLength(2)
expect(entries).toEqual(expect.arrayContaining([[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: '旧的。' })
const first = ended(qa, '1')
await qa.rewrite(uri, request('1'))
await first
rewriter.mockResolvedValueOnce({})
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')
const { end, entryId } = await second
expect(end).toEqual({ status: 'done', error: '模型没有按要求交回注释正文,没有改文件。' })
// 两条可能记在同一毫秒,按 id 找这一条
expect(rewritesOf(qa)[0].entries.find(entry => entry.id === entryId)).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([])
})
it('整个扩展一次只重写一段:别的文件的排在后面、报排队中,记在 rewriteCurrent 里;前一段写完自动开始;问 AI 照旧同时问', async () => {
const ctx = context()
const { qa, runner, rewriter } = service(ctx)
let finishFirst: (answer: CommentAnswer) => void = () => {}
rewriter.mockImplementationOnce(() => new Promise((resolve) => {
finishFirst = resolve
}))
rewriter.mockResolvedValueOnce({ body: '第二段。' })
const other = { fsPath: '/workspace/b.ts', toString: () => 'file:///workspace/b.ts' } as Uri
const events: RewriteEvent[] = []
qa.onDidRewrite(event => events.push(event))
const writeBack = vi.fn(async () => undefined)
expect(await qa.rewrite(uri, request('1'))).toBeUndefined()
expect(await qa.rewrite(other, request('2', { anchor: 'function g', writeBack }))).toBeUndefined()
expect(rewriter).toHaveBeenCalledTimes(1)
expect(events).toContainEqual({ owner: 'file:///workspace/b.ts', kind: 'update', ask: { askId: '2', anchor: 'function g', question: SKILL, text: '', queued: true } })
expect(qa.view(other, file => file.fsPath, 'file').rewriteCurrent).toEqual([{ askId: '2', anchor: 'function g', question: SKILL, text: '', status: 'queued' }])
await qa.ask(uri, { askId: '3', anchor: 'function f', question: '为什么', prompt: '完整的', followUp: '短的', images: [] })
await qa.ask(other, { askId: '4', anchor: 'function g', question: '为什么', prompt: '完整的', followUp: '短的', images: [] })
expect(runner).toHaveBeenCalledTimes(2)
const second = ended(qa, '2')
finishFirst({ body: '第一段。' })
expect((await second).end).toEqual({ status: 'done', body: '第二段。' })
expect(rewriter).toHaveBeenCalledTimes(2)
expect(writeBack).toHaveBeenCalledWith('第二段。')
})
})
describe('顶部聊天要结构化结果的一轮(了解一块代码)', () => {
/** 记在内存里的 workspaceState:顶部问答记在这里 */
function memento(): Pick<Memento, 'get' | 'update'> {
const values = new Map<string, unknown>()
return { get: <T>(key: string) => values.get(key) as T, update: async (key: string, value: unknown) => void values.set(key, value) }
}
function setup() {
const runner = vi.fn<AskRunner>()
const qa = new CodeQa(context(), memento(), runner)
services.push(qa)
return { qa, runner }
}
/** 等这一轮结束的事件 */
function ended(qa: CodeQa, askId: string): Promise<Extract<AskEvent, { kind: 'end' }>> {
return new Promise((resolve) => {
qa.onDidAnswer((event) => {
if (event.kind === 'end' && event.ask.askId === askId)
resolve(event)
})
})
}
const run = { prompt: '完整的话', followUp: '接着说的话', schema: '{"type":"object"}', addDirs: ['/ws/server'] }
const historyOf = (qa: CodeQa) => qa.view(uri, file => file.fsPath, 'feature').path
it('带上 schema 和加进来的目录;答完交给 settle,记下它给的回答;下一轮接着最近一次的会话说', async () => {
const { qa, runner } = setup()
const output = { groups: [] }
runner.mockImplementationOnce(async (ask) => {
await ask.onSession('s1')
return { text: '找到了。', output }
})
const settle = vi.fn(async () => ({ text: '整理好的回答' }))
const first = ended(qa, '1')
expect(await qa.askTop(uri, { askId: '1', question: '用户登录', images: [] }, run, settle)).toBeUndefined()
expect(runner.mock.calls[0][0]).toMatchObject({ prompt: '完整的话', schema: '{"type":"object"}', addDirs: ['/ws/server'], cwd: '/workspace' })
expect(runner.mock.calls[0][0]).not.toHaveProperty('followUp')
expect((await first).end).toEqual({ status: 'done', answer: { text: '整理好的回答', output } })
expect(settle).toHaveBeenCalledWith({ text: '找到了。', output })
expect(historyOf(qa)).toEqual([expect.objectContaining({ question: '用户登录', answer: '整理好的回答' })])
runner.mockResolvedValueOnce({ text: '', output })
const second = ended(qa, '2')
await qa.askTop(uri, { askId: '2', question: '还漏了续费失败重试', images: [] }, run, settle)
await second
expect(runner.mock.calls[1][0].followUp).toEqual({ sessionId: 's1', prompt: '接着说的话' })
expect(historyOf(qa).map(entry => entry.question)).toEqual(['用户登录', '还漏了续费失败重试'])
})
it('settle 说结构不合格:这一轮算失败、说清原因,不记', async () => {
const { qa, runner } = setup()
runner.mockResolvedValueOnce({ text: '只说了话' })
const failed = ended(qa, '1')
await qa.askTop(uri, { askId: '1', question: '用户登录', images: [] }, run, async () => ({ error: 'AI 没按要求的结构交回结果(只回了一段话)。' }))
expect((await failed).end).toEqual({ status: 'failed', error: 'AI 没按要求的结构交回结果(只回了一段话)。' })
expect(historyOf(qa)).toEqual([])
// 没答成的这一轮留着,页面重新加载后照样画出来
await vi.waitFor(() => expect(qa.view(uri, file => file.fsPath, 'feature').current).toMatchObject([{ askId: '1', status: 'failed', error: 'AI 没按要求的结构交回结果(只回了一段话)。' }]))
})
it('删掉这一页的全部问答:记下的没了,正在问的叫停', async () => {
const { qa, runner } = setup()
runner.mockResolvedValueOnce({ text: '', output: {} })
const first = ended(qa, '1')
await qa.askTop(uri, { askId: '1', question: '用户登录', images: [] }, run, async () => ({ text: '回答' }))
await first
runner.mockImplementationOnce(ask => new Promise((_resolve, reject) => {
ask.signal.addEventListener('abort', () => reject(new ClaudeCancelledError('')))
}))
const stopped = ended(qa, '2')
await qa.askTop(uri, { askId: '2', question: '接着找', images: [] }, run, async () => ({ text: '回答' }))
await qa.deleteTop(uri)
expect((await stopped).end).toEqual({ status: 'stopped' })
expect(historyOf(qa)).toEqual([])
})
})
describe('工作区外的库文件(⌘ 点进去的标准库源码)', () => {
const library = { fsPath: `${places.libraryDir}/pathlib.py`, toString: () => `file://${places.libraryDir}/pathlib.py` } as Uri
const viewOf = (qa: CodeQa, of: Uri = library) => qa.view(of, file => file.fsPath, 'file')
/** 等这一轮问答结束的事件 */
function answered(qa: CodeQa, askId: string): Promise<Extract<AskEvent, { kind: 'end' }>> {
return new Promise((resolve) => {
qa.onDidAnswer((event) => {
if (event.kind === 'end' && event.ask.askId === askId)
resolve(event)
})
})
}
it('能问 AI,不能重写注释(会改到库文件本身);项目里的文件两样都能', () => {
const { qa } = service(context())
expect(viewOf(qa)).toMatchObject({ available: true, rewritable: false })
expect(viewOf(qa, uri)).toMatchObject({ available: true, rewritable: true })
})
it('问一块:claude 在第一个工作区文件夹里跑、另外放行读库文件所在的目录;答完记进那个文件夹的表,再看能看到这条', async () => {
const { qa, runner } = service(context())
runner.mockResolvedValueOnce({ text: '按通配符递归找。' })
const done = answered(qa, '1')
expect(await qa.ask(library, { askId: '1', anchor: 'function rglob', question: '怎么找的', prompt: '完整的', followUp: '短的', images: [] })).toBeUndefined()
expect(runner.mock.calls[0][0]).toMatchObject({ cwd: '/workspace', addDirs: [places.libraryDir] })
expect((await done).end.status).toBe('done')
expect(viewOf(qa).threads).toEqual([{ anchor: 'function rglob', entries: [expect.objectContaining({ question: '怎么找的', answer: '按通配符递归找。' })] }])
expect(viewOf(qa, uri).threads).toEqual([])
})
it('顶部问这个文件:一样放行库文件所在的目录,发给模型的是绝对路径', async () => {
const { qa, runner } = service(context())
expect(await qa.askPath(library, 'file', { askId: '1', question: '这是什么', images: [] })).toBeUndefined()
expect(runner.mock.calls[0][0]).toMatchObject({ cwd: '/workspace', addDirs: [places.libraryDir] })
expect(runner.mock.calls[0][0].prompt).toContain(`绝对路径:${places.libraryDir}/pathlib.py`)
})
it('重写注释开不了,不启动模型', async () => {
const { qa, rewriter } = service(context())
const skill = '/ecc:writing-code-comments pathlib.py:1-3'
expect(await qa.rewrite(library, { askId: '1', anchor: 'function rglob', question: skill, prompt: skill, images: [], writeBack: async () => undefined })).toMatch(/不能重写注释/)
expect(rewriter).not.toHaveBeenCalled()
})
})
describe('没答完的那一轮也记着会话:接着说「继续」就接着它做', () => {
type RunLinesOptions = Parameters<typeof runClaudeLines>[0]
/** 假的 claude 每次起来干什么:吐出这些行(给了 error 再抛它),或一直不出声、等人叫停 */
type Step = { lines: string[], error?: Error } | 'hang'
const ANCHOR = 'function f'
const SKILL = '/ecc:writing-code-comments a.ts:1-3'
/** topQaLocation 的替身给 a.ts 的键 */
const TOP_KEY = 'file:///workspace\na.ts'
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const answerLine = (text: string): string => JSON.stringify({ type: 'result', subtype: 'success', is_error: false, result: text })
const structuredLine = (output: unknown): string => JSON.stringify({ type: 'result', subtype: 'success', is_error: false, result: JSON.stringify(output), structured_output: output })
const notFound = (id: string): ClaudeError => new ClaudeError(`Claude 退出码非零 (1): No conversation found with session ID: ${id}`, '')
const argAfter = (args: string[], flag: string): string | undefined => args.includes(flag) ? args[args.indexOf(flag) + 1] : undefined
const sentText = (call: RunLinesOptions): string => (JSON.parse(call.input ?? '') as { message: { content: { text: string }[] } }).message.content[0].text
/** 块下面这块在 path 那份记录文件里记着的会话 */
const blockSession = (path: string) => (): string | undefined =>
(places.stores.get(path)?.get(places.folder, 'a.ts') as { anchor: string, session?: string }[] | undefined)?.find(thread => thread.anchor === ANCHOR)?.session
/**
* 问 AI、重写注释都走真的 askClaude、askCommentBody,只把起 claude 换成按 steps 来的假的。
* atStart 是每次起进程那一刻这一处记着的会话(recorded 读出来的)。
*/
function setup(steps: Step[], recorded: (values: Map<string, unknown>) => string | undefined) {
const values = new Map<string, unknown>()
const memento = { get: <T>(key: string) => values.get(key) as T, update: async (key: string, value: unknown) => void values.set(key, value) }
const calls: RunLinesOptions[] = []
const atStart: (string | undefined)[] = []
const runLines = async (options: RunLinesOptions): Promise<void> => {
const step = steps[calls.length]
calls.push(options)
atStart.push(recorded(values))
if (step === 'hang') {
await new Promise<void>((_resolve, reject) => {
options.signal?.addEventListener('abort', () => reject(new ClaudeCancelledError('')))
})
return
}
step.lines.forEach(options.onLine)
if (step.error)
throw step.error
}
const qa = new CodeQa(context(), memento, run => askClaude(run, { runLines }), ask => askCommentBody(ask, { runLines, pluginRoot: async () => '/plugins/ecc' }))
services.push(qa)
return { qa, calls, atStart }
}
function answered(qa: CodeQa, askId: string): Promise<Extract<AskEvent, { kind: 'end' }>> {
return new Promise((resolve) => {
qa.onDidAnswer((event) => {
if (event.kind === 'end' && event.ask.askId === askId)
resolve(event)
})
})
}
function rewritten(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 block = (askId: string, question: string, followUp: string) => ({ askId, anchor: ANCHOR, question, prompt: `带代码的完整问题:${question}`, followUp, images: [] })
it('块下面问:会话 id 起进程前就记下;太久没动静停掉后说「继续吧」,--resume 同一个会话、发的是这句话;答成的照常记下,再问还接着它', async () => {
const { qa, calls, atStart } = setup([
{ lines: [], error: new ClaudeTimeoutError('Claude 连续 300s 没有输出', '') },
{ lines: [answerLine('接着找完了')] },
{ lines: [answerLine('又答了')] },
], blockSession('.spx/code-qa.json'))
const first = answered(qa, '1')
await qa.ask(uri, block('1', '用户是如何跟 code agent 聊天的', '短的问题'))
expect((await first).end).toEqual({ status: 'failed', error: '连续 5 分钟没有任何动静,已经停掉。接着说「继续」会从停下的地方接着做。' })
const id = argAfter(calls[0].args, '--session-id')
expect(id).toMatch(UUID)
expect(atStart[0]).toBe(id)
expect(sentText(calls[0])).toBe('带代码的完整问题:用户是如何跟 code agent 聊天的')
const second = answered(qa, '2')
await qa.ask(uri, block('2', '继续吧', '继续吧'))
expect((await second).end).toEqual({ status: 'done', answer: { text: '接着找完了' } })
expect(argAfter(calls[1].args, '--resume')).toBe(id)
expect(calls[1].args).not.toContain('--session-id')
expect(sentText(calls[1])).toBe('继续吧')
expect(qa.view(uri, file => file.fsPath, 'file').threads).toEqual([{ anchor: ANCHOR, entries: [expect.objectContaining({ question: '继续吧', answer: '接着找完了' })] }])
const third = answered(qa, '3')
await qa.ask(uri, block('3', '那会话存哪', '那会话存哪'))
await third
expect(argAfter(calls[2].args, '--resume')).toBe(id)
expect(sentText(calls[2])).toBe('那会话存哪')
})
it('顶部问文件:被用户停掉后说「继续吧」,--resume 同一个会话、发接着问的话;没答完的那一轮不进记录', async () => {
const { qa, calls, atStart } = setup(['hang', { lines: [answerLine('说完了')] }], values => parsePathQa(values.get(PATH_QA_STATE_KEY))[TOP_KEY]?.session)
const first = answered(qa, '1')
await qa.askPath(uri, 'file', { askId: '1', question: '这是什么', images: [] })
await vi.waitFor(() => expect(calls).toHaveLength(1))
qa.stop('1')
expect((await first).end).toEqual({ status: 'stopped' })
const id = argAfter(calls[0].args, '--session-id')
expect(id).toMatch(UUID)
expect(atStart[0]).toBe(id)
const second = answered(qa, '2')
await qa.askPath(uri, 'file', { askId: '2', question: '继续吧', images: [] })
expect((await second).end.status).toBe('done')
expect(argAfter(calls[1].args, '--resume')).toBe(id)
expect(sentText(calls[1])).toMatch(/^接着问(还是 a\.ts 这个文件,需要时重新读):继续吧/)
expect(qa.view(uri, file => file.fsPath, 'file').path.map(entry => [entry.question, entry.answer])).toEqual([['继续吧', '说完了']])
})
it('了解一块代码:Claude Code 出错后接着说,--resume 同一个会话、发接着说的话', async () => {
const { qa, calls } = setup([
{ lines: [], error: new ClaudeError('Claude 退出码非零 (1): 网络断了', '') },
{ lines: [structuredLine({ groups: [] })] },
], () => undefined)
const run = { prompt: '完整的要求', followUp: '接着说的话', schema: '{"type":"object"}' }
const first = answered(qa, '1')
await qa.askTop(uri, { askId: '1', question: '用户登录', images: [] }, run, async () => ({ text: '整理好的回答' }))
expect((await first).end).toEqual({ status: 'failed', error: 'Claude Code 没答成:Claude 退出码非零 (1): 网络断了' })
const id = argAfter(calls[0].args, '--session-id')
const second = answered(qa, '2')
await qa.askTop(uri, { askId: '2', question: '继续', images: [] }, run, async () => ({ text: '整理好的回答' }))
expect((await second).end.status).toBe('done')
expect(argAfter(calls[1].args, '--resume')).toBe(id)
expect(sentText(calls[1])).toBe('接着说的话')
})
it('重写注释:太久没动静停掉后接着说,--resume 同一个会话、发块在哪和用户的话,写回交回的正文', async () => {
const { qa, calls, atStart } = setup([
{ lines: [], error: new ClaudeTimeoutError('Claude 连续 600s 没有输出', '') },
{ lines: [structuredLine({ body: '按名字找文件。' })] },
], blockSession('.spx/comment-rewrites.json'))
const writeBack = vi.fn(async () => undefined)
const first = rewritten(qa, '1')
await qa.rewrite(uri, { askId: '1', anchor: ANCHOR, question: SKILL, prompt: SKILL, images: [], writeBack })
expect((await first).end).toEqual({ status: 'failed', error: '连续 10 分钟没有任何动静,已经停掉。接着说「继续」会从停下的地方接着写。' })
const id = argAfter(calls[0].args, '--session-id')
expect(id).toMatch(UUID)
expect(atStart[0]).toBe(id)
expect(writeBack).not.toHaveBeenCalled()
const second = rewritten(qa, '2')
await qa.rewrite(uri, { askId: '2', anchor: ANCHOR, question: '继续', prompt: `${SKILL}\n\n继续`, followUp: 'a.ts:1-3\n\n继续', images: [], writeBack })
expect((await second).end).toEqual({ status: 'done', body: '按名字找文件。' })
expect(argAfter(calls[1].args, '--resume')).toBe(id)
expect(sentText(calls[1])).toBe('a.ts:1-3\n\n继续')
expect(writeBack).toHaveBeenCalledWith('按名字找文件。')
})
it('记着的会话找不到了:生成新会话 id、起进程前记下,按完整问题重开;之后接着新的这个', async () => {
const steps: Step[] = ['hang']
const { qa, calls, atStart } = setup(steps, blockSession('.spx/code-qa.json'))
const first = answered(qa, '1')
await qa.ask(uri, block('1', '怎么聊天', '短的'))
await vi.waitFor(() => expect(calls).toHaveLength(1))
qa.stop('1')
await first
const lost = argAfter(calls[0].args, '--session-id') ?? ''
steps.push({ lines: [], error: notFound(lost) }, { lines: [answerLine('重新答了')] }, { lines: [answerLine('接着答')] })
const second = answered(qa, '2')
await qa.ask(uri, block('2', '继续吧', '继续吧'))
expect((await second).end.status).toBe('done')
expect(argAfter(calls[1].args, '--resume')).toBe(lost)
const fresh = argAfter(calls[2].args, '--session-id')
expect(fresh).toMatch(UUID)
expect(fresh).not.toBe(lost)
expect(atStart[2]).toBe(fresh)
expect(sentText(calls[2])).toBe('带代码的完整问题:继续吧')
const third = answered(qa, '3')
await qa.ask(uri, block('3', '再问', '再问'))
await third
expect(argAfter(calls[3].args, '--resume')).toBe(fresh)
})
})