✨ feat(vscode): 重写注释排成一队,整个扩展一次只跑一段

OpenPencil 同一时间只能开一个画板,两段同时画会互相掐断。所有预览、所有文件的重写(第一轮和接着说)排成一队,前一段写成、出错、被叫停后按先后自动开始下一段;问 AI 照旧最多同时 3 个。
排着的那一轮记在扩展端(rewriteCurrent 的 queued),块下面写「排队中」、能取消排队,切标签页、页面重新加载后照样显示。
This commit is contained in:
cruldra committed 2026-10-12 15:11:22 +11:00
1 parent b7c29c60e1
commit aecc4d1ab5
15 files changed
+329 -114

No files matched your search

+3 -3
View File
@@ -88,7 +88,7 @@ export interface QaThreadView {
/**
* 这份文件的问答和重写记录:available 为假时文件不在工作区文件夹里、没地方存,不给「问 AI」和「重写注释」;
* current、rewriteCurrent 是当前这一轮:正在答的问题、正在重写的注释,和出错、被叫停没记下留着的(页面重新加载后靠它们接上)。
* current、rewriteCurrent 是当前这一轮:正在答的问题、排着队和正在重写的注释,和出错、被叫停没记下留着的(页面重新加载后靠它们接上)。
*/
export interface QaMessage {
type: 'qa'
@@ -128,7 +128,7 @@ export interface StickyScrollSettings {
* askProfiles:「问 AI」输入框旁边的 profile 下拉:选项和选中的那项;ready 后发一次,任何一个预览改了都发给所有预览
* answer:askId 那个问题的回答长了(text 是到现在为止的全文)、或换了在做的事
* answerEnd:askId 那个问题答完了:done 带最终的回答和存下的那条的 id,failed 带给人看的原因,stopped 是被叫停
* rewriteProgress:askId 那一轮重写开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令)
* rewriteProgress:askId 那一轮重写排上队了(queued)、开始了、正文长了(text 是到现在为止写出来的)、或换了在做的事;question 是记在这一轮上的话(点按钮时是那句技能命令)
* rewriteEnd:askId 那一轮重写结束了:done 是模型答完、这一轮记下了,带交回的正文(text)、存下的那条的 id,没写进文件时带原因(error);
* failed 带给人看的原因,stopped 是被叫停,这两种都没记、文件没改
* moveOccurrence:跳到高亮着的名字的下一处(step 1)/ 上一处(step -1)出现,由这两条命令发给活动的预览
@@ -158,7 +158,7 @@ export type ExtensionToWebview
| ({ type: 'askProfiles' } & ProfileChoice)
| { type: 'answer', askId: string, text: string, activity?: string }
| { type: 'answerEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string }
| { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string }
| { type: 'rewriteProgress', askId: string, question: string, text: string, activity?: string, queued?: true }
| { type: 'rewriteEnd', askId: string, status: 'done' | 'failed' | 'stopped', text?: string, error?: string, entryId?: string }
| { type: 'diff', diff: DocumentDiff | null }
| { type: 'repoLinksChecked', id: number, missing: string[], unresolved: string[] }
@@ -303,8 +303,8 @@ class Preview {
if (event.owner !== this.subject().uri.toString() || !this.ready)
return
if (event.kind === 'update') {
const { askId, question, text, activity } = event.ask
this.post({ type: 'rewriteProgress', askId, question, text, ...(activity !== undefined && { activity }) })
const { askId, question, text, activity, queued } = event.ask
this.post({ type: 'rewriteProgress', askId, question, text, ...(activity !== undefined && { activity }), ...(queued && { queued }) })
return
}
const { end, entryId } = event
@@ -1020,8 +1020,6 @@ class Preview {
fail(refused)
return
}
// 页面点按钮时还不知道那句技能命令,先报上去
this.post({ type: 'rewriteProgress', askId, question, text: '' })
this.postQa()
}
+91 -11
View File
@@ -2,7 +2,7 @@ import type { AskEnd, AskStart, RunningAsk } from './asks'
import type { Answer, AskRun } from './claudeAsk'
import { describe, expect, it } from 'vitest'
import { ClaudeCancelledError, ClaudeError, ClaudeTimeoutError } from '../../cc/spawnClaude'
import { ASK_WORDING, QaAsks, REWRITE_WORDING } from './asks'
import { ASK_POLICY, QaAsks, REWRITE_POLICY } from './asks'
/** 假的回答:每次开问记下来,由测试决定什么时候答完 / 出错;叫停时像真的一样抛 ClaudeCancelledError */
function fakeRunner(): { runner: (run: AskRun) => Promise<Answer>, runs: (AskRun & { answer: (answer: Answer) => void, fail: (err: Error) => void })[] } {
@@ -38,7 +38,7 @@ function listener(): { updates: RunningAsk[], ends: [RunningAsk, AskEnd][], ende
}
describe('正在答的问题', () => {
it('回答长出来、换了在做的事都报给界面;答完报 done,不再算在答', async () => {
it('开始了、回答长出来、换了在做的事都报给界面;答完报 done,不再算在答', async () => {
const { runner, runs } = fakeRunner()
const asks = new QaAsks(runner)
const events = listener()
@@ -48,6 +48,7 @@ describe('正在答的问题', () => {
runs[0].onEvent({ kind: 'activity', label: '读取 m.py' })
runs[0].onEvent({ kind: 'text', text: '答' })
expect(events.updates).toEqual([
{ askId: '1', anchor: '1', question: '问 1', text: '' },
{ askId: '1', anchor: '1', question: '问 1', text: '', activity: '读取 m.py' },
{ askId: '1', anchor: '1', question: '问 1', text: '答' },
])
@@ -89,31 +90,33 @@ describe('正在答的问题', () => {
expect(asks.start(start('1', 'function g'), listener())).toBe('这个问题已经在问了。')
})
it('不同块可以同时答,最多几个;答完一个又能问', async () => {
it('不同块可以同时答,最多三个,再多不开始、说清楚;答完一个又能问', async () => {
const { runner, runs } = fakeRunner()
const asks = new QaAsks(runner, ASK_WORDING, 2)
const asks = new QaAsks(runner)
const first = listener()
asks.start(start('1'), first)
asks.start(start('2'), listener())
expect(asks.start(start('3'), listener())).toBe('同时最多问 2 个问题,等前面的答完再问。')
asks.start(start('3'), listener())
expect(asks.start(start('4'), listener())).toBe('同时最多问 3 个问题,等前面的答完再问。')
expect(runs).toHaveLength(3)
runs[0].answer({ text: '答', sessionId: 's' })
await first.ended
expect(asks.start(start('3'), listener())).toBeUndefined()
expect(asks.start(start('4'), listener())).toBeUndefined()
expect(runs).toHaveLength(4)
})
it('重写注释用自己的说法:这段已经在重写、同时重写的太多、等太久停掉', async () => {
it('重写注释用自己的说法:这段已经在重写(排着队也算)、等太久停掉', async () => {
const { runner, runs } = fakeRunner()
const rewrites = new QaAsks(runner, REWRITE_WORDING, 2)
const rewrites = new QaAsks(runner, REWRITE_POLICY)
const first = listener()
rewrites.start(start('1', 'function f'), first)
expect(rewrites.start(start('2', 'function f'), listener())).toBe('这段注释已经在重写了,等它写完或者先停下再说。')
rewrites.start(start('3', 'function g'), listener())
expect(rewrites.start(start('4', 'function h'), listener())).toBe('同时最多重写 2 段注释,等前面的写完再来。')
expect(rewrites.start(start('4', 'function g'), listener())).toBe('这段注释已经在重写了,等它写完或者先停下再说。')
runs[0].fail(new ClaudeTimeoutError('Claude 调用超时(900s)', ''))
await first.ended
expect(first.ends[0][1]).toEqual({ status: 'failed', error: '等了 15 分钟还没写完,已经停掉。' })
})
it('出错、叫停的那一轮没记下,留在这里给重新加载的页面再画出来;这块再开一轮、收起这块时去掉,别的文件不算', async () => {
const { runner, runs } = fakeRunner()
const asks = new QaAsks(runner)
@@ -137,7 +140,7 @@ describe('正在答的问题', () => {
it('留着的那一轮不占名额,收起时还在答的不去掉', async () => {
const { runner, runs } = fakeRunner()
const asks = new QaAsks(runner, ASK_WORDING, 1)
const asks = new QaAsks(runner, { ...ASK_POLICY, limit: 1 })
const failing = listener()
asks.start(start('1', 'function f'), failing)
runs[0].fail(new ClaudeError('网络断了', ''))
@@ -158,3 +161,80 @@ describe('正在答的问题', () => {
expect(runs.every(run => run.signal.aborted)).toBe(true)
})
})
describe('重写注释排成一队', () => {
const after = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 0))
it('同时只跑一段,不管哪份文件;后面的排着、报排队中,前一段写成、出错、被叫停后按先后自动开始下一段', async () => {
const { runner, runs } = fakeRunner()
const rewrites = new QaAsks(runner, REWRITE_POLICY)
const [a, b, c, d] = [listener(), listener(), listener(), listener()]
expect(rewrites.start(start('1', 'function f'), a)).toBeUndefined()
expect(rewrites.start(start('2', 'function g', 'file:///b.py'), b)).toBeUndefined()
expect(rewrites.start(start('3', 'function h'), c)).toBeUndefined()
expect(rewrites.start(start('4', 'function k', 'file:///b.py'), d)).toBeUndefined()
expect(runs.map(run => run.prompt)).toEqual(['完整的 1'])
expect(b.updates).toEqual([{ askId: '2', anchor: 'function g', question: '问 2', text: '', queued: true }])
expect(rewrites.currentFor('file:///a.py').map(ask => [ask.askId, ask.status])).toEqual([['1', 'running'], ['3', 'queued']])
expect(rewrites.currentFor('file:///b.py')).toEqual([
{ askId: '2', anchor: 'function g', question: '问 2', text: '', status: 'queued' },
{ askId: '4', anchor: 'function k', question: '问 4', text: '', status: 'queued' },
])
runs[0].answer({ text: '写好了', sessionId: 's1' })
await a.ended
await after()
expect(runs.map(run => run.prompt)).toEqual(['完整的 1', '完整的 2'])
expect(b.updates.at(-1)).toEqual({ askId: '2', anchor: 'function g', question: '问 2', text: '' })
expect(rewrites.currentFor('file:///b.py').map(ask => [ask.askId, ask.status])).toEqual([['2', 'running'], ['4', 'queued']])
runs[1].fail(new ClaudeError('网络断了', ''))
await b.ended
await after()
expect(runs.map(run => run.prompt)).toEqual(['完整的 1', '完整的 2', '完整的 3'])
rewrites.stop('3')
await c.ended
await after()
expect(runs.map(run => run.prompt)).toEqual(['完整的 1', '完整的 2', '完整的 3', '完整的 4'])
})
it('排着队的能取消:报 stopped,不再开始;排在它后面的照样轮到', async () => {
const { runner, runs } = fakeRunner()
const rewrites = new QaAsks(runner, REWRITE_POLICY)
const [a, b, c] = [listener(), listener(), listener()]
rewrites.start(start('1', 'function f'), a)
rewrites.start(start('2', 'function g'), b)
rewrites.start(start('3', 'function h'), c)
rewrites.stop('2')
await b.ended
expect(b.ends).toEqual([[{ askId: '2', anchor: 'function g', question: '问 2', text: '' }, { status: 'stopped' }]])
expect(rewrites.currentFor('file:///a.py').map(ask => [ask.askId, ask.status])).toEqual([['1', 'running'], ['2', 'stopped'], ['3', 'queued']])
runs[0].answer({ text: '写好了', sessionId: 's1' })
await a.ended
await after()
expect(runs.map(run => run.prompt)).toEqual(['完整的 1', '完整的 3'])
})
it('全部叫停时排着队的也结束,不再开始', async () => {
const { runner, runs } = fakeRunner()
const rewrites = new QaAsks(runner, REWRITE_POLICY)
const [a, b] = [listener(), listener()]
rewrites.start(start('1', 'function f'), a)
rewrites.start(start('2', 'function g'), b)
rewrites.stopAll()
await Promise.all([a.ended, b.ended])
await after()
expect(b.ends[0][1]).toEqual({ status: 'stopped' })
expect(runs).toHaveLength(1)
})
it('问 AI 不排队:同时问几个照旧都开始', () => {
const { runner, runs } = fakeRunner()
const asks = new QaAsks(runner)
asks.start(start('1'), listener())
asks.start(start('2'), listener())
expect(runs).toHaveLength(2)
expect(asks.currentFor('file:///a.py').map(ask => ask.status)).toEqual(['running', 'running'])
})
})
+104 -56
View File
@@ -1,5 +1,5 @@
/**
* 块下面正在进行的对话(问 AI、重写注释各一份):一块同时只进行一轮,不同块可以同时进行(最多 MAX_RUNNING 个),能叫停。
* 块下面正在进行的对话(问 AI、重写注释各一份):一块同时只进行一轮,能叫停;不同块同时进行几轮、满了怎么办按 RunPolicy。
* 不碰 VS Code:结束后怎么存、怎么告诉界面交给 AskListener。
*/
@@ -7,9 +7,6 @@ import type { Answer, AskRun } from './claudeAsk'
import { ClaudeCancelledError } from '../../cc/spawnClaude'
import { askFailure, rewriteFailure } from './claudeAsk'
/** 同时进行的最多几个:每个都是一个 claude 进程 */
export const MAX_RUNNING = 3
/** 正在进行的一轮,界面照它显示。 */
export interface RunningAsk {
askId: string
@@ -19,6 +16,8 @@ export interface RunningAsk {
text: string
/** 正在做的事:「读取 routes.py」 */
activity?: string
/** 还在排队,前面的结束了才开始 */
queued?: true
}
/** 进行中报上来的:会话 id、回答长出来的字、正在做的事。 */
@@ -52,28 +51,34 @@ export interface AskListener<Result = Answer> {
end: (ask: RunningAsk, end: AskEnd<Result>) => void | Promise<void>
}
/** 给人看的话:这块已经在进行、同时进行的太多、没成的原因。 */
export interface RunWording {
/** 一种对话怎么跑:同时最多几轮(每轮一个 claude 进程),满了是不开始(refuse,回给人看的话)还是排在后面(queue),以及给人看的话。 */
export interface RunPolicy {
limit: number
whenFull: { kind: 'refuse', message: (limit: number) => string } | { kind: 'queue' }
/** 这块已经在进行 */
busy: string
full: (limit: number) => string
/** 没成的原因 */
failure: (err: unknown) => string
}
export const ASK_WORDING: RunWording = {
export const ASK_POLICY: RunPolicy = {
limit: 3,
whenFull: { kind: 'refuse', message: limit => `同时最多问 ${limit} 个问题,等前面的答完再问。` },
busy: '这里已经在问了,等它答完或者先停下再问。',
full: limit => `同时最多问 ${limit} 个问题,等前面的答完再问。`,
failure: askFailure,
}
export const REWRITE_WORDING: RunWording = {
/** 重写注释一次只跑一段:OpenPencil 同一时间只能开一个画板,两段同时画,一边 `op stop` 另一边就跟着断。 */
export const REWRITE_POLICY: RunPolicy = {
limit: 1,
whenFull: { kind: 'queue' },
busy: '这段注释已经在重写了,等它写完或者先停下再说。',
full: limit => `同时最多重写 ${limit} 段注释,等前面的写完再来。`,
failure: rewriteFailure,
}
/** 这一轮眼下怎样:还在进行,或出错、被叫停(没记下,留着给人看)。 */
export interface AskRound extends RunningAsk {
status: 'running' | 'failed' | 'stopped'
/** 这一轮眼下怎样:排着队、还在进行,或出错、被叫停(没记下,留着给人看)。 */
export interface AskRound extends Omit<RunningAsk, 'queued'> {
status: 'queued' | 'running' | 'failed' | 'stopped'
/** failed 时给人看的原因 */
error?: string
}
@@ -82,12 +87,15 @@ interface Round {
owner: string
ask: RunningAsk
controller: AbortController
/** 出错、被叫停了;还在进行时没有 */
/** 排着队时才有:轮到了怎么开始,取消了怎么结束 */
waiting?: { launch: () => void, cancel: () => void }
/** 出错、被叫停了;还没结束时没有 */
end?: Exclude<AskEnd, { status: 'done' }>
}
/**
* Run 是跑一轮要的全部,叫停的开关和进行中的消息由这里给。
* 满了按 RunPolicy 不开始或排在后面;排着的按先后,前面结束一轮(答成、出错、被叫停都算)就开始下一轮。
* 答成了的那一轮交给 listener 记下,这里就不管了;出错、被叫停的没记下,留在这里(页面重新加载后靠 currentFor 再画出来),
* 这块再开一轮、或页面上收起这块时去掉(forget)。
*/
@@ -96,72 +104,91 @@ export class QaAsks<Run extends { signal: AbortSignal } = AskRun, Result = Answe
constructor(
private readonly runner: (run: Run) => Promise<Result>,
private readonly wording: RunWording = ASK_WORDING,
private readonly limit = MAX_RUNNING,
private readonly policy: RunPolicy = ASK_POLICY,
) {}
/** 开始一轮;这块已经在进行、同时进行的太多时不开始,返回原因(给人看的话)。这块留着的上一轮去掉。 */
/** 开始一轮,满了按 RunPolicy 排在后面;这块已经在进行或排着、满了又不排队时不开始,返回原因(给人看的话)。这块留着的上一轮去掉。 */
start(start: AskStart<Omit<Run, keyof RunControl>>, listener: AskListener<Result>): string | undefined {
const { owner, askId, anchor, question } = start
if (this.rounds.has(askId))
return '这个问题已经在问了。'
const running = [...this.rounds.values()].filter(round => !round.end)
if (running.some(round => round.owner === owner && round.ask.anchor === anchor))
return this.wording.busy
if (running.length >= this.limit)
return this.wording.full(this.limit)
if ([...this.rounds.values()].some(round => !round.end && round.owner === owner && round.ask.anchor === anchor))
return this.policy.busy
const full = this.running() >= this.policy.limit
if (full && this.policy.whenFull.kind === 'refuse')
return this.policy.whenFull.message(this.policy.limit)
this.forget(owner, anchor)
const controller = new AbortController()
const ask: RunningAsk = { askId, anchor, question, text: '' }
const round: Round = { owner, ask, controller }
const round: Round = { owner, ask, controller: new AbortController() }
this.rounds.set(askId, round)
const control: RunControl = {
signal: controller.signal,
onEvent: (event) => {
if (event.kind === 'text') {
ask.text = event.text
delete ask.activity
}
else if (event.kind === 'activity') {
ask.activity = event.label
}
else {
return
}
listener.update({ ...ask })
},
}
// Run 里除了这两项就是 start.run,拼回去还是 Run
void this.runner({ ...start.run, ...control } as unknown as Run).then(
(answer): AskEnd<Result> => ({ status: 'done', answer }),
(err: unknown): AskEnd<Result> => err instanceof ClaudeCancelledError ? { status: 'stopped' } : { status: 'failed', error: this.wording.failure(err) },
).then(async (end) => {
const finish = async (end: AskEnd<Result>): Promise<void> => {
if (end.status === 'done')
this.rounds.delete(askId)
else
round.end = end
await listener.end({ ...ask }, end)
})
try {
await listener.end({ ...ask }, end)
}
finally {
this.next()
}
}
const launch = (): void => {
listener.update({ ...ask })
const control: RunControl = {
signal: round.controller.signal,
onEvent: (event) => {
if (event.kind === 'text') {
ask.text = event.text
delete ask.activity
}
else if (event.kind === 'activity') {
ask.activity = event.label
}
else {
return
}
listener.update({ ...ask })
},
}
// Run 里除了这两项就是 start.run,拼回去还是 Run
void this.runner({ ...start.run, ...control } as unknown as Run).then(
(answer): AskEnd<Result> => ({ status: 'done', answer }),
(err: unknown): AskEnd<Result> => err instanceof ClaudeCancelledError ? { status: 'stopped' } : { status: 'failed', error: this.policy.failure(err) },
).then(finish)
}
if (!full) {
launch()
return undefined
}
ask.queued = true
round.waiting = { launch, cancel: () => void finish({ status: 'stopped' }) }
listener.update({ ...ask })
return undefined
}
/** 叫停:结束 claude 进程,这次不存。 */
/** 叫停:结束 claude 进程,这次不存;排着队的取消,不再开始。 */
stop(askId: string): void {
const round = this.rounds.get(askId)
if (round && !round.end)
if (!round || round.end)
return
const waiting = this.dequeue(round)
if (waiting)
waiting.cancel()
else
round.controller.abort()
}
/** owner 这份文件当前的各轮:还在进行的,和出错、被叫停留着的,按开始的先后。 */
/** owner 这份文件当前的各轮:排着队的、还在进行的,和出错、被叫停留着的,按开始的先后。 */
currentFor(owner: string): AskRound[] {
return [...this.rounds.values()].filter(round => round.owner === owner).map(({ ask, end }) => ({
return [...this.rounds.values()].filter(round => round.owner === owner).map(({ ask: { queued, ...ask }, end }) => ({
...ask,
status: end?.status ?? 'running',
status: end?.status ?? (queued ? 'queued' : 'running'),
...(end?.status === 'failed' && { error: end.error }),
}))
}
/** 去掉 owner 这份文件 anchor 那块留着的那一轮(页面上收起了这块);还在进行的不动。 */
/** 去掉 owner 这份文件 anchor 那块留着的那一轮(页面上收起了这块);还在进行、排着的不动。 */
forget(owner: string, anchor: string): void {
for (const [askId, round] of this.rounds) {
if (round.end && round.owner === owner && round.ask.anchor === anchor)
@@ -169,11 +196,32 @@ export class QaAsks<Run extends { signal: AbortSignal } = AskRun, Result = Answe
}
}
/** 全部叫停(插件停用时)。 */
/** 全部叫停(插件停用时)。排着的同步取消,前面的停下后不会再开始它们。 */
stopAll(): void {
for (const askId of [...this.rounds.keys()])
this.stop(askId)
}
/** 正在跑的有几轮(排着的、结束了的不算) */
private running(): number {
return [...this.rounds.values()].filter(round => !round.end && !round.waiting).length
}
/** 让这一轮不再排队,返回它排队时的办法;没在排队时没有。 */
private dequeue(round: Round): Round['waiting'] {
const { waiting } = round
delete round.waiting
delete round.ask.queued
return waiting
}
/** 有空位就按先后开始排着的。 */
private next(): void {
for (const round of this.rounds.values()) {
if (this.running() >= this.policy.limit)
return
if (!round.end)
round.controller.abort()
this.dequeue(round)?.launch()
}
}
}
+37 -3
View File
@@ -182,7 +182,10 @@ describe('重写注释的记录', () => {
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], ['短一点', '短的。', '编辑器没接受这次修改,没有写回。']])
// 两条可能记在同一毫秒,不比先后
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')
@@ -199,8 +202,10 @@ describe('重写注释的记录', () => {
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: '模型没有按要求交回注释正文,没有改文件。' })
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 () => {
@@ -227,4 +232,33 @@ describe('重写注释的记录', () => {
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: '第二段。', sessionId: 'r2' })
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: '第一段。', sessionId: 'r1' })
expect((await second).end).toEqual({ status: 'done', body: '第二段。' })
expect(rewriter).toHaveBeenCalledTimes(2)
expect(writeBack).toHaveBeenCalledWith('第二段。')
})
})
+2 -2
View File
@@ -13,7 +13,7 @@ import { getSettings, onDidChangeSettings } from '../../settings/store'
import { pathQaLocation } from '../editor/pathPreview'
import { FolderStore, locate } from '../folderStore'
import { AskProfiles } from './askProfiles'
import { QaAsks, REWRITE_WORDING } from './asks'
import { QaAsks, REWRITE_POLICY } from './asks'
import { askClaude, askCommentBody } from './claudeAsk'
import { describePath } from './pathAskDescribe'
import { pathAskFollowUp, pathAskPrompt } from './pathAskPrompt'
@@ -135,7 +135,7 @@ export class CodeQa implements Disposable {
})
})
this.asks = new QaAsks(runner)
this.rewrites = new QaAsks(rewriter, REWRITE_WORDING)
this.rewrites = new QaAsks(rewriter, REWRITE_POLICY)
}
/** uri 这份文件或文件夹的问答带的图片放在哪个目录(预览要能读到它才画得出缩略图);不在工作区文件夹里时没有。 */