🐛 fix(vscode): 代码阅读问 AI 不再按总时长停,连续 5 分钟(重写注释 10 分钟)没有任何输出才停;新会话的 id 由扩展定好(--session-id)、起进程前就记在这一处,没答完就超时、出错、被叫停的那一轮说「继续」也 --resume 同一个会话接着做,会话找不到才按完整问题重开

This commit is contained in:
cruldra committed 2026-10-14 20:46:36 +11:00
1 parent a0c3df3bca
commit 20da8da4c9
17 files changed
+803 -277

No files matched your search

+216 -18
View File
@@ -1,4 +1,5 @@
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'
@@ -6,9 +7,11 @@ 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 { 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')
@@ -40,7 +43,9 @@ const places = vi.hoisted(() => {
const locateQa = (uri: { fsPath: string }) => inWorkspace(uri)
? { folder, key: 'a.ts', promptPath: 'a.ts' }
: { folder, key: libraryKey, promptPath: uri.fsPath, outsideDir: libraryDir }
return { folder, libraryDir, libraryKey, inWorkspace, locateQa }
/** 每份记录文件(`.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', () => ({
@@ -48,6 +53,10 @@ 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) ?? {}))
@@ -175,7 +184,7 @@ describe('问 AI 和重写注释各用各的 profile', () => {
const ctx = context()
await saveSettings(ctx, { defaultAskProfilePath: '/p/sonnet.json', defaultRewriteProfilePath: '/p/dsflash.json' })
const { qa, runner, rewriter } = service(ctx)
rewriter.mockResolvedValue({ body: '新的。', sessionId: 'r1' })
rewriter.mockResolvedValue({ body: '新的。' })
await qa.ask(uri, asking('1'))
await qa.askPath(uri, 'file', { askId: '2', question: '这是什么', images: [] })
qa.stop('2')
@@ -187,7 +196,7 @@ describe('问 AI 和重写注释各用各的 profile', () => {
it('都没配时两套都用默认的那个', async () => {
const { qa, runner, rewriter } = service(context())
rewriter.mockResolvedValue({ body: '新的。', sessionId: 'r1' })
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')
@@ -197,7 +206,7 @@ describe('问 AI 和重写注释各用各的 profile', () => {
it('临时切换只改自己那一套、只通知自己那一套的下拉,不写进设置', async () => {
const ctx = context()
const { qa, runner, rewriter } = service(ctx)
rewriter.mockResolvedValue({ body: '新的。', sessionId: 'r1' })
rewriter.mockResolvedValue({ body: '新的。' })
const askShown = vi.fn()
const rewriteShown = vi.fn()
qa.profiles.ask.subscribe(askShown)
@@ -260,7 +269,10 @@ describe('重写注释的记录', () => {
it('答完就记在重写记录里、不进问答:写进文件的记正文;接着说时接这块最近一次重写的会话,写回失败也记下原因', async () => {
const ctx = context()
const { qa, runner, rewriter } = service(ctx)
rewriter.mockResolvedValueOnce({ body: '按名字找。', sessionId: 'r1' })
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: [] })
@@ -269,7 +281,7 @@ describe('重写注释的记录', () => {
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' })
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 }))
@@ -288,11 +300,11 @@ describe('重写注释的记录', () => {
it('点按钮从头重写(不带 followUp)开新会话;模型没交回正文也记下原因', async () => {
const ctx = context()
const { qa, rewriter } = service(ctx)
rewriter.mockResolvedValueOnce({ body: '旧的。', sessionId: 'r1' })
rewriter.mockResolvedValueOnce({ body: '旧的。' })
const first = ended(qa, '1')
await qa.rewrite(uri, request('1'))
await first
rewriter.mockResolvedValueOnce({ sessionId: 'r2' })
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')
@@ -334,7 +346,7 @@ describe('重写注释的记录', () => {
rewriter.mockImplementationOnce(() => new Promise((resolve) => {
finishFirst = resolve
}))
rewriter.mockResolvedValueOnce({ body: '第二段。', sessionId: 'r2' })
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))
@@ -350,7 +362,7 @@ describe('重写注释的记录', () => {
expect(runner).toHaveBeenCalledTimes(2)
const second = ended(qa, '2')
finishFirst({ body: '第一段。', sessionId: 'r1' })
finishFirst({ body: '第一段。' })
expect((await second).end).toEqual({ status: 'done', body: '第二段。' })
expect(rewriter).toHaveBeenCalledTimes(2)
expect(writeBack).toHaveBeenCalledWith('第二段。')
@@ -387,17 +399,20 @@ describe('顶部聊天要结构化结果的一轮(了解一块代码)', () =
it('带上 schema 和加进来的目录;答完交给 settle,记下它给的回答;下一轮接着最近一次的会话说', async () => {
const { qa, runner } = setup()
const output = { groups: [] }
runner.mockResolvedValueOnce({ text: '找到了。', sessionId: 's1', output })
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: '整理好的回答', sessionId: 's1', output } })
expect(settle).toHaveBeenCalledWith({ text: '找到了。', sessionId: 's1', output })
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: '', sessionId: 's1', output })
runner.mockResolvedValueOnce({ text: '', output })
const second = ended(qa, '2')
await qa.askTop(uri, { askId: '2', question: '还漏了续费失败重试', images: [] }, run, settle)
await second
@@ -407,7 +422,7 @@ describe('顶部聊天要结构化结果的一轮(了解一块代码)', () =
it('settle 说结构不合格:这一轮算失败、说清原因,不记', async () => {
const { qa, runner } = setup()
runner.mockResolvedValueOnce({ text: '只说了话', sessionId: 's1' })
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 没按要求的结构交回结果(只回了一段话)。' })
@@ -418,7 +433,7 @@ describe('顶部聊天要结构化结果的一轮(了解一块代码)', () =
it('删掉这一页的全部问答:记下的没了,正在问的叫停', async () => {
const { qa, runner } = setup()
runner.mockResolvedValueOnce({ text: '', sessionId: 's1', output: {} })
runner.mockResolvedValueOnce({ text: '', output: {} })
const first = ended(qa, '1')
await qa.askTop(uri, { askId: '1', question: '用户登录', images: [] }, run, async () => ({ text: '回答' }))
await first
@@ -455,7 +470,7 @@ describe('工作区外的库文件(⌘ 点进去的标准库源码)', () =>
it('问一块:claude 在第一个工作区文件夹里跑、另外放行读库文件所在的目录;答完记进那个文件夹的表,再看能看到这条', async () => {
const { qa, runner } = service(context())
runner.mockResolvedValueOnce({ text: '按通配符递归找。', sessionId: 's1' })
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] })
@@ -478,3 +493,186 @@ describe('工作区外的库文件(⌘ 点进去的标准库源码)', () =>
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)
})
})