🐛 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

+78 -9
View File
@@ -1,7 +1,7 @@
import { promises as fsp } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from './spawnClaude'
let dir: string
@@ -25,23 +25,23 @@ describe('跑 claude、逐行交出 stdout', () => {
it('每出来完整的一行交一行,最后没带换行的那段结束时交出;参数原样传、在 cwd 里跑', async () => {
const bin = await fakeBin('printf \'{"a":1}\\n{"b":\'; sleep 0.1; printf \'2}\\n\'; pwd; printf "%s|" "$@"')
const lines: string[] = []
await runClaudeLines({ bin, args: ['-p', '带 空格 的问题'], cwd: dir, onLine: item => lines.push(item) })
await runClaudeLines({ bin, args: ['-p', '带 空格 的问题'], cwd: dir, idleTimeoutMs: 5000, onLine: item => lines.push(item) })
expect(lines).toEqual(['{"a":1}', '{"b":2}', await fsp.realpath(dir), '-p|带 空格 的问题|'])
})
it('给了 input 就写进 stdin 再关上(stream-json 的消息);不给时 stdin 是空的,读 stdin 的也不会卡住', async () => {
const bin = await fakeBin('cat; echo end')
const withInput: string[] = []
await runClaudeLines({ bin, args: [], cwd: dir, input: '{"type":"user"}\n', onLine: item => withInput.push(item) })
await runClaudeLines({ bin, args: [], cwd: dir, input: '{"type":"user"}\n', idleTimeoutMs: 5000, onLine: item => withInput.push(item) })
expect(withInput).toEqual(['{"type":"user"}', 'end'])
const without: string[] = []
await runClaudeLines({ bin, args: [], cwd: dir, timeoutMs: 2000, onLine: item => without.push(item) })
await runClaudeLines({ bin, args: [], cwd: dir, idleTimeoutMs: 2000, onLine: item => without.push(item) })
expect(without).toEqual(['end'])
})
it('退出码非零:带上 stderr 的原话', async () => {
const bin = await fakeBin('echo "No conversation found with session ID: x" >&2; exit 1')
const error = await runClaudeLines({ bin, args: [], cwd: dir, onLine: () => {} }).catch((err: unknown) => err)
const error = await runClaudeLines({ bin, args: [], cwd: dir, idleTimeoutMs: 5000, onLine: () => {} }).catch((err: unknown) => err)
expect(error).toBeInstanceOf(ClaudeError)
expect((error as ClaudeError).message).toBe('Claude 退出码非零 (1): No conversation found with session ID: x')
})
@@ -51,16 +51,85 @@ describe('跑 claude、逐行交出 stdout', () => {
const bin = await fakeBin(`echo started; sleep 2; touch "${marker}"`)
const controller = new AbortController()
const started = Date.now()
const run = runClaudeLines({ bin, args: [], cwd: dir, signal: controller.signal, onLine: () => controller.abort() })
const run = runClaudeLines({ bin, args: [], cwd: dir, idleTimeoutMs: 5000, signal: controller.signal, onLine: () => controller.abort() })
await expect(run).rejects.toBeInstanceOf(ClaudeCancelledError)
expect(Date.now() - started).toBeLessThan(1500)
await new Promise(resolve => setTimeout(resolve, 2500))
await expect(fsp.access(marker)).rejects.toThrow()
await expect(runClaudeLines({ bin, args: [], cwd: dir, signal: controller.signal, onLine: () => {} })).rejects.toBeInstanceOf(ClaudeCancelledError)
await expect(runClaudeLines({ bin, args: [], cwd: dir, idleTimeoutMs: 5000, signal: controller.signal, onLine: () => {} })).rejects.toBeInstanceOf(ClaudeCancelledError)
}, 10_000)
it('超时:结束进程,报 ClaudeTimeoutError', async () => {
it('一开始就一行都不出:等满 idleTimeoutMs 结束进程,报 ClaudeTimeoutError', async () => {
const bin = await fakeBin('sleep 5')
await expect(runClaudeLines({ bin, args: [], cwd: dir, timeoutMs: 200, onLine: () => {} })).rejects.toBeInstanceOf(ClaudeTimeoutError)
await expect(runClaudeLines({ bin, args: [], cwd: dir, idleTimeoutMs: 200, onLine: () => {} })).rejects.toBeInstanceOf(ClaudeTimeoutError)
})
})
describe('按连续多久没动静停,不限总时长', () => {
const FIVE_MINUTES = 5 * 60_000
// 只换掉计时器,进程的输出照真实时间来
beforeEach(() => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
})
afterEach(() => {
vi.useRealTimers()
})
it('一直在出:每来一行过 1 分钟,总共过了 8 分钟也不停,进程自己结束就正常结束', async () => {
const bin = await fakeBin('for i in 1 2 3 4 5 6 7 8; do echo "$i"; sleep 0.05; done')
const lines: string[] = []
await runClaudeLines({
bin,
args: [],
cwd: dir,
idleTimeoutMs: FIVE_MINUTES,
onLine: (line) => {
lines.push(line)
vi.advanceTimersByTime(60_000)
},
})
expect(lines).toEqual(['1', '2', '3', '4', '5', '6', '7', '8'])
})
it('出了一行后连续 5 分钟没新的一行:差 1 毫秒时还在等,到点结束进程、报 ClaudeTimeoutError', async () => {
const bin = await fakeBin('echo first; sleep 5; echo late')
let first: () => void = () => {}
const gotFirst = new Promise<void>((resolve) => {
first = resolve
})
let settled = false
const run = runClaudeLines({ bin, args: [], cwd: dir, idleTimeoutMs: FIVE_MINUTES, onLine: () => first() }).finally(() => {
settled = true
})
await gotFirst
vi.advanceTimersByTime(FIVE_MINUTES - 1)
await new Promise(resolve => setImmediate(resolve))
expect(settled).toBe(false)
vi.advanceTimersByTime(1)
await expect(run).rejects.toBeInstanceOf(ClaudeTimeoutError)
await expect(run).rejects.toThrow('Claude 连续 300s 没有输出')
})
it('一直在出、已经过了 5 分钟:用户叫停照样马上停,报 ClaudeCancelledError', async () => {
const bin = await fakeBin('while true; do echo tick; sleep 0.05; done')
const controller = new AbortController()
let count = 0
const run = runClaudeLines({
bin,
args: [],
cwd: dir,
idleTimeoutMs: FIVE_MINUTES,
signal: controller.signal,
onLine: () => {
count += 1
vi.advanceTimersByTime(60_000)
if (count === 8)
controller.abort()
},
})
await expect(run).rejects.toBeInstanceOf(ClaudeCancelledError)
expect(count).toBe(8)
})
})
+19 -9
View File
@@ -560,9 +560,10 @@ function spawnClaudeStreamed(
}
/**
* 跑一次 claude(参数由调用方拼好,一般带 `--output-format stream-json`),stdout 每出来完整的一行就交给 onLine。
* 正常退出时 resolve;找不到 claude、退出码非零、输出过大时 reject ClaudeError,超时 ClaudeTimeoutError,
* signal 叫停时结束进程并 reject ClaudeCancelledError。
* 跑一次 claude(参数由调用方拼好,一般带 `--output-format stream-json`),stdout 每出来完整的一行就交给 `onLine`;只要还在出就一直等,不限总时长。
*
* 正常退出时 resolve;找不到 claude、退出码非零、输出过大时 reject [ClaudeError],连续 `idleTimeoutMs` 没出新的一行时结束进程并 reject [ClaudeTimeoutError],
* `signal` 叫停时结束进程并 reject [ClaudeCancelledError]。
*/
export function runClaudeLines(opts: {
args: string[]
@@ -570,12 +571,13 @@ export function runClaudeLines(opts: {
/** 写进 stdin 后关上(`--input-format stream-json` 的消息,见 buildStreamJsonLine);不给时 stdin 是空的 */
input?: string
onLine: (line: string) => void
timeoutMs?: number
/** 连续这么多毫秒 stdout 没出新的一行,就当 claude 卡住了 */
idleTimeoutMs: number
signal?: AbortSignal
/** 默认按 resolveClaudeBin 找 */
bin?: string
}): Promise<void> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
const { idleTimeoutMs } = opts
const bin = opts.bin ?? resolveClaudeBin()
if (!bin)
return Promise.reject(new ClaudeError(CLAUDE_MISSING_MSG, ''))
@@ -587,7 +589,6 @@ export function runClaudeLines(opts: {
let stderr = ''
let stdoutBytes = 0
let settled = false
const lines = createLineFeeder(opts.onLine)
const child = spawn(bin, opts.args, {
cwd: opts.cwd,
@@ -598,10 +599,19 @@ export function runClaudeLines(opts: {
child.stdin.on('error', () => {})
child.stdin.end(opts.input ?? '')
const timer = setTimeout(() => {
const idle = (): void => {
child.kill('SIGKILL')
settle(new ClaudeTimeoutError(`Claude 调用超时(${Math.round(timeoutMs / 1000)}s)`, stderr))
}, timeoutMs)
settle(new ClaudeTimeoutError(`Claude 连续 ${Math.round(idleTimeoutMs / 1000)}s 没有输出`, stderr))
}
let timer = setTimeout(idle, idleTimeoutMs)
// 每出来一行都说明 claude 还在干活,从这一行起重新计时
const lines = createLineFeeder((line) => {
if (settled)
return
clearTimeout(timer)
timer = setTimeout(idle, idleTimeoutMs)
opts.onLine(line)
})
function onAbort(): void {
child.kill('SIGTERM')
settle(new ClaudeCancelledError(stderr))
+5 -5
View File
@@ -156,18 +156,18 @@ describe('读模型交回的结构化结果', () => {
].flatMap(parse)
expect(events[0]).toEqual({ kind: 'activity', label: '读取 a.py' })
expect(events.filter(event => event.kind === 'text').map(event => 'text' in event && event.text)).toEqual(['把 `', '把 `a` 和 `b` 相加。'])
expect(events.at(-1)).toEqual({ kind: 'result', body: '把 `a` 和 `b` 相加。', sessionId: 's1' })
expect(events.at(-1)).toEqual({ kind: 'result', body: '把 `a` 和 `b` 相加。' })
})
it('会话 id 以 result 行的为准,没有时用开头 init 行的', () => {
it('init 行什么都不报:会话 id 由扩展定,不从输出里读', () => {
const parse = createCommentParser()
expect(parse(line({ type: 'system', subtype: 'init', session_id: 's0' }))).toEqual([])
expect(parse(resultLine({ structured_output: { body: '正文' } }))).toEqual([{ kind: 'result', body: '正文', sessionId: 's0' }])
expect(parse(resultLine({ structured_output: { body: '正文' }, session_id: 's0' }))).toEqual([{ kind: 'result', body: '正文' }])
})
it('模型只回了一段话、structured_output 是空的:结果里没有正文;Claude Code 报错时是失败', () => {
expect(createCommentParser()(resultLine({ result: '我改好了:把 a 和 b 相加。', session_id: 's1' }))).toEqual([{ kind: 'result', sessionId: 's1' }])
expect(createCommentParser()(resultLine({ result: '', structured_output: { body: ' ' }, session_id: 's1' }))).toEqual([{ kind: 'result', sessionId: 's1' }])
expect(createCommentParser()(resultLine({ result: '我改好了:把 a 和 b 相加。', session_id: 's1' }))).toEqual([{ kind: 'result' }])
expect(createCommentParser()(resultLine({ result: '', structured_output: { body: ' ' }, session_id: 's1' }))).toEqual([{ kind: 'result' }])
expect(createCommentParser()(line({ type: 'result', is_error: true, result: '额度用完了' }))).toEqual([{ kind: 'error', message: '额度用完了' }])
})
})
+3 -9
View File
@@ -245,12 +245,12 @@ export function commentFollowUp(path: string, startLine: number, endLine: number
/**
* 从 claude 的 stream-json 输出里读出来的:text 是注释正文到现在为止写出来的(结构化结果逐字长出来),activity 是正在做的事(「读取 routes.py」),
* result 是答完了(body 是结构化结果里的正文,没按 COMMENT_SCHEMA 交回、正文是空的时没有;sessionId 是这次的会话),error 是 Claude Code 自己报的失败。
* result 是答完了(body 是结构化结果里的正文,没按 COMMENT_SCHEMA 交回、正文是空的时没有),error 是 Claude Code 自己报的失败。
*/
export type CommentEvent
= | { kind: 'text', text: string }
| { kind: 'activity', label: string }
| { kind: 'result', body?: string, sessionId: string }
| { kind: 'result', body?: string }
| { kind: 'error', message: string }
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -290,8 +290,6 @@ export function createCommentParser(): (line: string) => CommentEvent[] {
/** 正在写的结构化结果(工具输入的 JSON 原文);不在写时没有 */
let json: string | undefined
let text = ''
/** 开头 init 行报的会话 id;result 行没带时用它 */
let sessionId = ''
const grow = (body: string | undefined): CommentEvent[] => {
if (body === undefined || body === text)
return []
@@ -328,10 +326,6 @@ export function createCommentParser(): (line: string) => CommentEvent[] {
// 子代理的消息(parent_tool_use_id 不空)不是这次的结果
if (!isRecord(line) || (line.parent_tool_use_id !== undefined && line.parent_tool_use_id !== null))
return []
if (line.type === 'system' && line.subtype === 'init') {
sessionId = str(line.session_id)
return []
}
if (line.type === 'stream_event')
return isRecord(line.event) ? streamEvent(line.event) : []
if (line.type === 'assistant')
@@ -343,7 +337,7 @@ export function createCommentParser(): (line: string) => CommentEvent[] {
return [{ kind: 'error', message: str(line.result).trim() || errors || str(line.subtype) || '没说原因' }]
}
const body = bodyOf(line.structured_output)
return [{ kind: 'result', ...(body !== undefined && { body }), sessionId: str(line.session_id) || sessionId }]
return [{ kind: 'result', ...(body !== undefined && { body }) }]
}
}
@@ -37,26 +37,24 @@ function run(lines: string[]): AnswerEvent[] {
}
describe('stream-json → 问答的进展', () => {
it('会话 id、逐字长出来的回答、读文件、最终回答;新的一条回复一开口就换掉前面那句', () => {
it('逐字长出来的回答、读文件、最终回答;新的一条回复一开口就换掉前面那句;init 等 system 行不报东西(会话 id 由扩展定)', () => {
expect(run(TRANSCRIPT)).toEqual([
{ kind: 'session', sessionId: SESSION },
{ kind: 'text', text: '我先看看' },
{ kind: 'text', text: '我先看看 m.py。' },
{ kind: 'activity', label: '读取 m.py' },
{ kind: 'text', text: '`add` 返回' },
{ kind: 'text', text: '`add` 返回 `a + b`(`m.py:1`)。' },
{ kind: 'result', text: '`add` 返回 `a + b`(`m.py:1`)。', sessionId: SESSION },
{ kind: 'result', text: '`add` 返回 `a + b`(`m.py:1`)。' },
])
})
it('没开逐字片段时按整条 assistant 消息更新,结果一样', () => {
const whole = TRANSCRIPT.filter(item => !item.includes('"stream_event"'))
expect(run(whole)).toEqual([
{ kind: 'session', sessionId: SESSION },
{ kind: 'text', text: '我先看看 m.py。' },
{ kind: 'activity', label: '读取 m.py' },
{ kind: 'text', text: '`add` 返回 `a + b`(`m.py:1`)。' },
{ kind: 'result', text: '`add` 返回 `a + b`(`m.py:1`)。', sessionId: SESSION },
{ kind: 'result', text: '`add` 返回 `a + b`(`m.py:1`)。' },
])
})
@@ -73,7 +71,7 @@ describe('stream-json → 问答的进展', () => {
it('result 行没带回答时用眼下这条回复', () => {
expect(run([delta('答案'), line({ type: 'result', subtype: 'success', is_error: false, result: '', session_id: SESSION })]).at(-1))
.toEqual({ kind: 'result', text: '答案', sessionId: SESSION })
.toEqual({ kind: 'result', text: '答案' })
})
it('要了结构化结果(--json-schema):交结果的那次工具调用是「整理结果」,result 行带上交回的结构;result 里那份 JSON 不当回答的字', () => {
@@ -83,10 +81,10 @@ describe('stream-json → 问答的进展', () => {
line({ type: 'result', subtype: 'success', is_error: false, result: JSON.stringify(output), structured_output: output, session_id: SESSION }),
])).toEqual([
{ kind: 'activity', label: '整理结果' },
{ kind: 'result', text: '', sessionId: SESSION, output },
{ kind: 'result', text: '', output },
])
expect(run([delta('找到了。'), line({ type: 'result', is_error: false, result: '{}', structured_output: {}, session_id: SESSION })]).at(-1))
.toEqual({ kind: 'result', text: '找到了。', sessionId: SESSION, output: {} })
.toEqual({ kind: 'result', text: '找到了。', output: {} })
})
it('claude 自己报的失败:取它说的原因', () => {
+4 -13
View File
@@ -1,6 +1,6 @@
/**
* 把 `claude -p --output-format stream-json --verbose --include-partial-messages` 吐出的每行 NDJSON
* 换成问答要的几件事:会话 id、回答逐字长出来、正在读什么文件、答完 / 出错。
* 换成问答要的几件事:回答逐字长出来、正在读什么文件、答完 / 出错。会话 id 由扩展定(见 [askClaude](vscode/src/codeReader/qa/claudeAsk.ts#askClaude)),不从这里读。
*
* 回答显示的是眼下这条回复:Claude 先说一句「我去看看 X」、读完文件再回答时,新回复一开口就换掉前面那句,
* 答完以 result 行里的最终回复为准(就是最后那条回复)。没开 partial messages 时按整条 assistant 消息更新,结果一样。
@@ -9,13 +9,12 @@
import { describeToolUse } from '../../cc/claudeProgress'
export type AnswerEvent
= | { kind: 'session', sessionId: string }
/** 眼下这条回复到现在为止的全文(Markdown) */
| { kind: 'text', text: string }
/** 眼下这条回复到现在为止的全文(Markdown) */
= | { kind: 'text', text: string }
/** 正在做的事:「读取 routes.py」「搜索 get_user」 */
| { kind: 'activity', label: string }
/** 答完了;要了结构化结果(--json-schema)时 output 是交回的结构(result 行的 structured_output),没要、没交时没有 */
| { kind: 'result', text: string, sessionId: string, output?: unknown }
| { kind: 'result', text: string, output?: unknown }
/** Claude Code 自己报的失败(result 行 is_error),进程多半也会以非零退出 */
| { kind: 'error', message: string }
@@ -30,7 +29,6 @@ function str(value: unknown): string {
/** 一个问题一个解析器:它记着眼下这条回复写到哪了。 */
export function createAnswerParser(): (line: string) => AnswerEvent[] {
let text = ''
let sessionId = ''
/** 新的一条回复开始了,下一个字换掉前面那条 */
let fresh = false
/** 见过逐字的片段:整条 assistant 消息里的字就不再算,免得重复 */
@@ -91,7 +89,6 @@ export function createAnswerParser(): (line: string) => AnswerEvent[] {
return [{
kind: 'result',
text: output === undefined ? str(line.result).trim() || text : text,
sessionId: str(line.session_id) || sessionId,
...(output !== undefined && { output }),
}]
}
@@ -108,12 +105,6 @@ export function createAnswerParser(): (line: string) => AnswerEvent[] {
if (!isRecord(line) || (line.parent_tool_use_id !== undefined && line.parent_tool_use_id !== null))
return []
switch (line.type) {
case 'system':
if (line.subtype === 'init' && typeof line.session_id === 'string') {
sessionId = line.session_id
return [{ kind: 'session', sessionId }]
}
return []
case 'stream_event':
return isRecord(line.event) ? streamEvent(line.event) : []
case 'assistant':
+6 -4
View File
@@ -8,6 +8,8 @@ const DIR = '/home/me/profiles'
const profile = (name: string): ClaudeProfile => ({ name, path: `${DIR}/${name}.json` })
const PROFILES = [profile('dsflash'), profile('offical'), profile('sonnet')]
const DEFAULT_PATH = `${DIR}/offical.json`
/** 拼参数时随便给的一个新会话,这里只看 --settings */
const NEW_SESSION = { id: 'new-1', resume: false }
function source(profiles: ClaudeProfile[] = PROFILES): ProfileSource {
return { list: async () => profiles, defaultPath: () => DEFAULT_PATH, directory: () => DIR }
@@ -74,7 +76,7 @@ describe('默认模型与临时选择', () => {
expect(choice.selected).toBe('')
expect(choice.options).toContainEqual(DEFAULT_OPTION)
expect(await profiles.current()).toBeUndefined()
expect(askArgs({ profilePath: (await profiles.current())?.path })).not.toContain('--settings')
expect(askArgs({ profilePath: (await profiles.current())?.path, session: NEW_SESSION })).not.toContain('--settings')
}
})
@@ -117,7 +119,7 @@ describe('默认模型与临时选择', () => {
const configured = `${DIR}/dsflash.json`
const profiles = new AskProfiles('问答', () => configured, source())
expect(await profiles.current()).toEqual(profile('dsflash'))
expect(askArgs({ profilePath: (await profiles.current())?.path }).slice(0, 2)).toEqual(['--settings', configured])
expect(askArgs({ profilePath: (await profiles.current())?.path, session: NEW_SESSION }).slice(0, 2)).toEqual(['--settings', configured])
await profiles.select('sonnet')
expect((await profiles.choice()).selected).toBe('sonnet')
expect(await profiles.current()).toEqual(profile('sonnet'))
@@ -183,11 +185,11 @@ describe('默认模型与临时选择', () => {
const profiles = new AskProfiles('问答', () => `${DIR}/sonnet.json`, source())
const chosen = await profiles.current()
expect(chosen).toEqual(profile('sonnet'))
expect(askArgs({ profilePath: chosen?.path }).slice(0, 2)).toEqual(['--settings', `${DIR}/sonnet.json`])
expect(askArgs({ profilePath: chosen?.path, session: NEW_SESSION }).slice(0, 2)).toEqual(['--settings', `${DIR}/sonnet.json`])
const none = await new AskProfiles('问答', () => '', source([])).current()
expect(none).toBeUndefined()
expect(askArgs({ profilePath: none?.path })).not.toContain('--settings')
expect(askArgs({ profilePath: none?.path, session: NEW_SESSION })).not.toContain('--settings')
})
it('没选过:用插件的默认 profile(和以前问 AI 用的一样)', async () => {
+9 -10
View File
@@ -17,7 +17,7 @@ function fakeRunner(): { runner: (run: AskRun) => Promise<Answer>, runs: (AskRun
}
function start(askId: string, anchor = askId, owner = 'file:///a.py'): AskStart {
return { owner, askId, anchor, question: `问 ${askId}`, run: { prompt: `完整的 ${askId}`, images: [], cwd: '/work' } }
return { owner, askId, anchor, question: `问 ${askId}`, run: { prompt: `完整的 ${askId}`, images: [], cwd: '/work', onSession: async () => {} } }
}
function listener(): { updates: RunningAsk[], ends: [RunningAsk, AskEnd][], ended: Promise<void>, update: (ask: RunningAsk) => void, end: (ask: RunningAsk, end: AskEnd) => void } {
@@ -44,7 +44,6 @@ describe('正在答的问题', () => {
const events = listener()
expect(asks.start(start('1'), events)).toBeUndefined()
expect(runs[0]).toMatchObject({ prompt: '完整的 1', cwd: '/work' })
runs[0].onEvent({ kind: 'session', sessionId: 's' })
runs[0].onEvent({ kind: 'activity', label: '读取 m.py' })
runs[0].onEvent({ kind: 'text', text: '答' })
expect(events.updates).toEqual([
@@ -53,9 +52,9 @@ describe('正在答的问题', () => {
{ askId: '1', anchor: '1', question: '问 1', text: '答' },
])
expect(asks.currentFor('file:///a.py')).toEqual([{ askId: '1', anchor: '1', question: '问 1', text: '答', status: 'running' }])
runs[0].answer({ text: '答案', sessionId: 's' })
runs[0].answer({ text: '答案' })
await events.ended
expect(events.ends).toEqual([[{ askId: '1', anchor: '1', question: '问 1', text: '答' }, { status: 'done', answer: { text: '答案', sessionId: 's' } }]])
expect(events.ends).toEqual([[{ askId: '1', anchor: '1', question: '问 1', text: '答' }, { status: 'done', answer: { text: '答案' } }]])
expect(asks.currentFor('file:///a.py')).toEqual([])
})
@@ -95,7 +94,7 @@ describe('正在答的问题', () => {
return { status: 'failed', error: '交回的结果不对:没有环节列表。' }
},
})
runs[0].answer({ text: '', sessionId: 's' })
runs[0].answer({ text: '' })
await done
await Promise.resolve()
expect(asks.currentFor('file:///a.py')).toEqual([{ askId: '1', anchor: '1', question: '问 1', text: '', status: 'failed', error: '交回的结果不对:没有环节列表。' }])
@@ -121,7 +120,7 @@ describe('正在答的问题', () => {
asks.start(start('3'), listener())
expect(asks.start(start('4'), listener())).toBe('同时最多问 3 个问题,等前面的答完再问。')
expect(runs).toHaveLength(3)
runs[0].answer({ text: '答', sessionId: 's' })
runs[0].answer({ text: '答' })
await first.ended
expect(asks.start(start('4'), listener())).toBeUndefined()
expect(runs).toHaveLength(4)
@@ -135,9 +134,9 @@ describe('正在答的问题', () => {
expect(rewrites.start(start('2', 'function f'), listener())).toBe('这段注释已经在重写了,等它写完或者先停下再说。')
rewrites.start(start('3', 'function g'), listener())
expect(rewrites.start(start('4', 'function g'), listener())).toBe('这段注释已经在重写了,等它写完或者先停下再说。')
runs[0].fail(new ClaudeTimeoutError('Claude 调用超时(900s)', ''))
runs[0].fail(new ClaudeTimeoutError('Claude 连续 600s 没有输出', ''))
await first.ended
expect(first.ends[0][1]).toEqual({ status: 'failed', error: '等了 15 分钟还没写完,已经停掉。' })
expect(first.ends[0][1]).toEqual({ status: 'failed', error: '连续 10 分钟没有任何动静,已经停掉。接着说「继续」会从停下的地方接着写。' })
})
it('出错、叫停的那一轮没记下,留在这里给重新加载的页面再画出来;这块再开一轮、收起这块时去掉,别的文件不算', async () => {
const { runner, runs } = fakeRunner()
@@ -203,7 +202,7 @@ describe('重写注释排成一队', () => {
{ askId: '4', anchor: 'function k', question: '问 4', text: '', status: 'queued' },
])
runs[0].answer({ text: '写好了', sessionId: 's1' })
runs[0].answer({ text: '写好了' })
await a.ended
await after()
expect(runs.map(run => run.prompt)).toEqual(['完整的 1', '完整的 2'])
@@ -232,7 +231,7 @@ describe('重写注释排成一队', () => {
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' })
runs[0].answer({ text: '写好了' })
await a.ended
await after()
expect(runs.map(run => run.prompt)).toEqual(['完整的 1', '完整的 3'])
+3 -7
View File
@@ -20,10 +20,9 @@ export interface RunningAsk {
queued?: true
}
/** 进行中报上来的:会话 id、回答长出来的字、正在做的事。 */
/** 进行中报上来的:回答长出来的字、正在做的事。 */
export type RunEvent
= | { kind: 'session', sessionId: string }
| { kind: 'text', text: string }
= | { kind: 'text', text: string }
| { kind: 'activity', label: string }
/** 跑一轮要的:叫停的开关,进行中的消息交给谁。 */
@@ -153,11 +152,8 @@ export class QaAsks<Run extends { signal: AbortSignal } = AskRun, Result = Answe
ask.text = event.text
delete ask.activity
}
else if (event.kind === 'activity') {
ask.activity = event.label
}
else {
return
ask.activity = event.label
}
listener.update({ ...ask })
},
+177 -51
View File
@@ -3,7 +3,7 @@ import type { AskDeps, AskRun, CommentAsk, CommentAskDeps } from './claudeAsk'
import { describe, expect, it } from 'vitest'
import { CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError } from '../../cc/spawnClaude'
import { COMMENT_SCHEMA } from '../commentRewrite'
import { askArgs, askClaude, askCommentBody, askFailure, commentPluginRoot, pluginRootIn, REWRITE_TIMEOUT_MS, rewriteArgs, rewriteFailure } from './claudeAsk'
import { ASK_IDLE_TIMEOUT_MS, askArgs, askClaude, askCommentBody, askFailure, commentPluginRoot, pluginRootIn, REWRITE_IDLE_TIMEOUT_MS, rewriteArgs, rewriteFailure } from './claudeAsk'
type RunLinesOptions = Parameters<typeof runClaudeLines>[0]
@@ -29,11 +29,39 @@ function fakeClaude(runs: { lines: string[], error?: Error }[]): { deps: AskDeps
}
}
function request(overrides: Partial<AskRun> = {}): AskRun & { events: unknown[] } {
/** 一次提问;onSession 记下的会话 id 在 sessions 里 */
function request(overrides: Partial<AskRun> = {}): AskRun & { events: unknown[], sessions: string[] } {
const events: unknown[] = []
return { prompt: '完整的问题', images: [], cwd: '/work', signal: new AbortController().signal, onEvent: event => events.push(event), events, ...overrides }
const sessions: string[] = []
return {
prompt: '完整的问题',
images: [],
cwd: '/work',
signal: new AbortController().signal,
onEvent: event => events.push(event),
onSession: async (id) => {
sessions.push(id)
},
events,
sessions,
...overrides,
}
}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
/** 参数里 flag 后面跟的那一项;没有这个 flag 时没有 */
function argAfter(args: string[], flag: string): string | undefined {
const at = args.indexOf(flag)
return at < 0 ? undefined : args[at + 1]
}
/** 等已经排上的异步都跑完(onSession 记下之前进程不该起来) */
const settle = (): Promise<void> => new Promise(resolve => setImmediate(resolve))
const NEW = { id: 'new-1', resume: false }
const RESUME = { id: 'session-1', resume: true }
const PNG = { mediaType: 'image/png', base64: 'iVBORw0KGgo=' }
const JPEG = { mediaType: 'image/jpeg', base64: '/9j/4AAQ' }
@@ -49,8 +77,8 @@ function sent(call: RunLinesOptions): { text: string, images: unknown[] } {
}
describe('命令行参数', () => {
it('只读:只给读、搜的工具,别的一律拒、不等人批,不加载 MCP;不带跳过权限;问题从 stdin 按 stream-json 进,输出逐字的 stream-json', () => {
const args = askArgs({ profilePath: '/p/offical.json' })
it('只读:只给读、搜的工具,别的一律拒、不等人批,不加载 MCP;不带跳过权限;开新会话用扩展给的 id;问题从 stdin 按 stream-json 进,输出逐字的 stream-json', () => {
const args = askArgs({ profilePath: '/p/offical.json', session: NEW })
expect(args).toEqual([
'--settings',
'/p/offical.json',
@@ -61,6 +89,8 @@ describe('命令行参数', () => {
'--permission-mode',
'dontAsk',
'--strict-mcp-config',
'--session-id',
'new-1',
'-p',
'--input-format',
'stream-json',
@@ -69,17 +99,19 @@ describe('命令行参数', () => {
'--verbose',
'--include-partial-messages',
])
expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions|Edit|Write|Bash/)
expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions|Edit|Write|Bash|--resume|--fork-session/)
})
it('追问带 --resume,放在 -p 前面;没有 profile 就不带 --settings', () => {
const args = askArgs({ resume: 'session-1' })
it('追问带 --resume、不带 --session-id,放在 -p 前面;没有 profile 就不带 --settings', () => {
const args = askArgs({ session: RESUME })
expect(args).not.toContain('--settings')
expect(args).not.toContain('--session-id')
expect(args).not.toContain('--fork-session')
expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 3)).toEqual(['--resume', 'session-1', '-p'])
})
it('要结构化结果时带 --json-schema;别的工作区文件夹用 --add-dir 加进来读;照样只读、能接着会话', () => {
const args = askArgs({ resume: 'session-1', schema: '{"type":"object"}', addDirs: ['/ws/server', '/ws/docs'] })
const args = askArgs({ session: RESUME, schema: '{"type":"object"}', addDirs: ['/ws/server', '/ws/docs'] })
expect(args.slice(0, 4)).toEqual(['--tools', 'Read,Grep,Glob', '--allowedTools', 'Read,Grep,Glob'])
expect(args.slice(args.indexOf('--permission-mode'), args.indexOf('--permission-mode') + 2)).toEqual(['--permission-mode', 'dontAsk'])
expect(args).toContain('--strict-mcp-config')
@@ -87,7 +119,7 @@ describe('命令行参数', () => {
expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 3)).toEqual(['--resume', 'session-1', '-p'])
expect(args.slice(-2)).toEqual(['--json-schema', '{"type":"object"}'])
expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions|Edit|Write|Bash/)
expect(askArgs({ addDirs: [] })).not.toContain('--add-dir')
expect(askArgs({ session: NEW, addDirs: [] })).not.toContain('--add-dir')
})
})
@@ -123,21 +155,52 @@ describe('问题连同图片从 stdin 发', () => {
})
describe('问一次', () => {
it('在工作区文件夹里跑、带超时和叫停;过程交给 onEvent,答完返回最终回答和会话 id', async () => {
const { deps, calls } = fakeClaude([{ lines: [init('s1'), delta('答'), delta('案'), result('答案', 's1')] }])
it('在工作区文件夹里跑、按没动静计时、能叫停;过程交给 onEvent,答完返回最终回答', async () => {
const { deps, calls } = fakeClaude([{ lines: [init('claude-起的'), delta('答'), delta('案'), result('答案', 'claude-起的')] }])
const run = request({ profilePath: '/profiles/sonnet.json' })
expect(await askClaude(run, deps)).toEqual({ text: '答案', sessionId: 's1' })
expect(run.events).toEqual([{ kind: 'session', sessionId: 's1' }, { kind: 'text', text: '答' }, { kind: 'text', text: '答案' }])
expect(await askClaude(run, deps)).toEqual({ text: '答案' })
expect(run.events).toEqual([{ kind: 'text', text: '答' }, { kind: 'text', text: '答案' }])
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({ cwd: '/work', signal: run.signal, timeoutMs: 300_000 })
expect(calls[0]).toMatchObject({ cwd: '/work', signal: run.signal, idleTimeoutMs: ASK_IDLE_TIMEOUT_MS })
expect(ASK_IDLE_TIMEOUT_MS).toBe(5 * 60_000)
expect(calls[0].args.slice(0, 2)).toEqual(['--settings', '/profiles/sonnet.json'])
expect(sent(calls[0]).text).toBe('完整的问题')
})
it('开新会话:会话 id 由扩展生成,先等 onSession 记下才起进程,参数带 --session-id 这个 id;init、result 行报的会话 id 不用', async () => {
const { deps, calls } = fakeClaude([{ lines: [init('claude-起的'), result('答案', 'claude-起的')] }])
let saved: () => void = () => {}
const sessions: string[] = []
const answer = askClaude(request({
onSession: (id) => {
sessions.push(id)
return new Promise((resolve) => {
saved = resolve
})
},
}), deps)
await settle()
expect(sessions).toHaveLength(1)
expect(sessions[0]).toMatch(UUID)
expect(calls).toHaveLength(0)
saved()
expect(await answer).toEqual({ text: '答案' })
expect(argAfter(calls[0].args, '--session-id')).toBe(sessions[0])
expect(calls[0].args).not.toContain('--resume')
})
it('每次开新会话的 id 都不一样', async () => {
const first = request()
const second = request()
await askClaude(first, fakeClaude([{ lines: [result('答案', 's')] }]).deps)
await askClaude(second, fakeClaude([{ lines: [result('答案', 's')] }]).deps)
expect(first.sessions[0]).not.toBe(second.sessions[0])
})
it('要结构化结果:参数带上 schema 和加进来的目录,交回的结构化结果一并返回;只交了结构化结果、没说话也算答了', async () => {
const output = { groups: [] }
const { deps, calls } = fakeClaude([{ lines: [init('s1'), line({ type: 'result', subtype: 'success', is_error: false, result: '', structured_output: output, session_id: 's1' })] }])
expect(await askClaude(request({ schema: '{"type":"object"}', addDirs: ['/ws/b'] }), deps)).toEqual({ text: '', sessionId: 's1', output })
expect(await askClaude(request({ schema: '{"type":"object"}', addDirs: ['/ws/b'] }), deps)).toEqual({ text: '', output })
expect(calls[0].args.slice(-2)).toEqual(['--json-schema', '{"type":"object"}'])
expect(calls[0].args).toContain('/ws/b')
})
@@ -148,22 +211,30 @@ describe('问一次', () => {
expect(calls[0].args).not.toContain('--settings')
})
it('追问接着上次的会话问短问题;换了 profile 也接着问,带上这次选的 --settings', async () => {
it('追问 --resume 上次的会话、发短问题,不开新会话、不再记会话;换了 profile 也接着问,带上这次选的 --settings', async () => {
const { deps, calls } = fakeClaude([{ lines: [result('接着答', 's1')] }])
expect(await askClaude(request({ profilePath: '/profiles/opus.json', followUp: { sessionId: 's1', prompt: '短问题' } }), deps)).toEqual({ text: '接着答', sessionId: 's1' })
const run = request({ profilePath: '/profiles/opus.json', followUp: { sessionId: 's1', prompt: '短问题' } })
expect(await askClaude(run, deps)).toEqual({ text: '接着答' })
expect(calls[0].args.slice(0, 2)).toEqual(['--settings', '/profiles/opus.json'])
expect(calls[0].args.slice(calls[0].args.indexOf('--resume'), calls[0].args.indexOf('--resume') + 2)).toEqual(['--resume', 's1'])
expect(argAfter(calls[0].args, '--resume')).toBe('s1')
expect(calls[0].args).not.toContain('--session-id')
expect(sent(calls[0]).text).toBe('短问题')
expect(run.sessions).toEqual([])
})
it('上次的会话找不到了:按完整问题重新开一次', async () => {
it('上次的会话找不到了:生成新的会话 id、记下后按完整问题重新开一次', async () => {
const { deps, calls } = fakeClaude([
{ lines: [], error: new ClaudeError('Claude 退出码非零 (1): No conversation found with session ID: s0', 'No conversation found with session ID: s0') },
{ lines: [init('s2'), result('重新答', 's2')] },
])
expect(await askClaude(request({ profilePath: '/profiles/opus.json', followUp: { sessionId: 's0', prompt: '短问题' } }), deps)).toEqual({ text: '重新答', sessionId: 's2' })
const run = request({ profilePath: '/profiles/opus.json', followUp: { sessionId: 's0', prompt: '短问题' } })
expect(await askClaude(run, deps)).toEqual({ text: '重新答' })
expect(calls).toHaveLength(2)
expect(argAfter(calls[0].args, '--resume')).toBe('s0')
expect(run.sessions).toHaveLength(1)
expect(run.sessions[0]).toMatch(UUID)
expect(calls[1].args).not.toContain('--resume')
expect(argAfter(calls[1].args, '--session-id')).toBe(run.sessions[0])
expect(calls[1].args.slice(0, 2)).toEqual(['--settings', '/profiles/opus.json'])
expect(sent(calls[1]).text).toBe('完整的问题')
})
@@ -183,21 +254,35 @@ describe('问一次', () => {
expect(calls).toHaveLength(2)
})
it('别的失败不重试,照样抛出来;Claude 报的失败、没给回答也算失败', async () => {
it('别的失败不重试,照样抛出来、不开新会话;Claude 报的失败、没给回答也算失败', async () => {
const timeout = new ClaudeTimeoutError('超时', '')
const failing = fakeClaude([{ lines: [], error: timeout }, { lines: [result('不该到这', 's')] }])
await expect(askClaude(request({ followUp: { sessionId: 's0', prompt: '短问题' } }), failing.deps)).rejects.toBe(timeout)
const run = request({ followUp: { sessionId: 's0', prompt: '短问题' } })
await expect(askClaude(run, failing.deps)).rejects.toBe(timeout)
expect(failing.calls).toHaveLength(1)
expect(run.sessions).toEqual([])
await expect(askClaude(request(), fakeClaude([{ lines: [line({ type: 'result', is_error: true, result: 'Invalid API key · Please run /login' })] }]).deps))
.rejects
.toThrow('Invalid API key')
await expect(askClaude(request(), fakeClaude([{ lines: [init('s')] }]).deps)).rejects.toThrow('没给出回答')
})
it('新会话没答完就停了:会话 id 已经记下,下次用它接着问', async () => {
const run = request()
const stopped = fakeClaude([{ lines: [init('s'), delta('答到一半')], error: new ClaudeCancelledError('') }])
await expect(askClaude(run, stopped.deps)).rejects.toBeInstanceOf(ClaudeCancelledError)
expect(run.sessions).toHaveLength(1)
const again = fakeClaude([{ lines: [result('接着答完', 's')] }])
const next = request({ followUp: { sessionId: run.sessions[0], prompt: '继续' } })
expect(await askClaude(next, again.deps)).toEqual({ text: '接着答完' })
expect(argAfter(again.calls[0].args, '--resume')).toBe(run.sessions[0])
expect(sent(again.calls[0]).text).toBe('继续')
})
})
describe('失败了给人看的话', () => {
it('超时、没装、没登录、别的', () => {
expect(askFailure(new ClaudeTimeoutError('超时', ''))).toBe('等了 5 分钟还没答完,已经停掉。可以把问题问得具体一点再试。')
it('太久没动静、没装、没登录、别的', () => {
expect(askFailure(new ClaudeTimeoutError('超时', ''))).toBe('连续 5 分钟没有任何动静,已经停掉。接着说「继续」会从停下的地方接着做。')
expect(askFailure(new ClaudeError(CLAUDE_MISSING_MSG, ''))).toContain('没找到 Claude Code')
expect(askFailure(new ClaudeError('Claude 退出码非零 (1): Invalid API key · Please run /login', ''))).toMatch(/^Claude Code 没登录或登录过期了:在终端里运行 claude 登录一下再问。/)
expect(askFailure(new ClaudeError('Claude 退出码非零 (1): 网络断了', ''))).toBe('Claude Code 没答成:Claude 退出码非零 (1): 网络断了')
@@ -209,7 +294,7 @@ describe('重写注释的命令行参数', () => {
const allowed = 'Read,Grep,Glob,Bash(/Applications/OpenPencil.app/Contents/MacOS/op *),Bash(mkdir -p docs/prototype/*/openpencil/exports),Edit(/docs/prototype/*/openpencil/**)'
it('不读用户、项目的设置(--restricted),写注释的插件单独加载、能读;只放开读搜、OpenPencil 的 op、建原型的 exports 目录、改原型目录,别的一律拒、不加载 MCP;按 JSON Schema 交结果', () => {
const args = rewriteArgs({ pluginRoot: '/plugins/ecc', profilePath: '/p/sonnet.json' })
const args = rewriteArgs({ pluginRoot: '/plugins/ecc', profilePath: '/p/sonnet.json', session: NEW })
expect(args).toEqual([
'--settings',
'/p/sonnet.json',
@@ -227,6 +312,8 @@ describe('重写注释的命令行参数', () => {
'--permission-mode',
'dontAsk',
'--strict-mcp-config',
'--session-id',
'new-1',
'-p',
'--input-format',
'stream-json',
@@ -241,16 +328,18 @@ describe('重写注释的命令行参数', () => {
expect(JSON.parse(COMMENT_SCHEMA)).toEqual({ type: 'object', properties: { body: { type: 'string' } }, required: ['body'], additionalProperties: false })
})
it('接着说时 --resume 那次会话,放在 -p 前面;没有 profile 就不带 --settings', () => {
const args = rewriteArgs({ pluginRoot: '/plugins/ecc', resume: 's1' })
it('接着说时 --resume 那次会话、不带 --session-id,放在 -p 前面;没有 profile 就不带 --settings', () => {
const args = rewriteArgs({ pluginRoot: '/plugins/ecc', session: { id: 's1', resume: true } })
expect(args[0]).toBe('--restricted')
expect(args).not.toContain('--session-id')
expect(args).not.toContain('--fork-session')
expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 3)).toEqual(['--resume', 's1', '-p'])
})
it('开新会话不带 --resume,会话 id 由 Claude Code 起', () => {
const args = rewriteArgs({ pluginRoot: '/plugins/ecc' })
it('开新会话不带 --resume,会话 id 用扩展给的(--session-id)', () => {
const args = rewriteArgs({ pluginRoot: '/plugins/ecc', session: NEW })
expect(args).not.toContain('--resume')
expect(args).not.toContain('--session-id')
expect(argAfter(args, '--session-id')).toBe('new-1')
})
})
@@ -296,50 +385,87 @@ describe('重写注释问一次', () => {
return { deps: commentDeps, calls, lookups }
}
function comment(overrides: Partial<CommentAsk> = {}): CommentAsk & { events: unknown[] } {
/** 一次重写;onSession 记下的会话 id 在 sessions 里 */
function comment(overrides: Partial<CommentAsk> = {}): CommentAsk & { events: unknown[], sessions: string[] } {
const events: unknown[] = []
return { prompt: '/ecc:writing-code-comments src/a.py:3-8', images: [], cwd: '/work', signal: new AbortController().signal, onEvent: event => events.push(event), events, ...overrides }
const sessions: string[] = []
return {
prompt: '/ecc:writing-code-comments src/a.py:3-8',
images: [],
cwd: '/work',
signal: new AbortController().signal,
onEvent: event => events.push(event),
onSession: async (id) => {
sessions.push(id)
},
events,
sessions,
...overrides,
}
}
it('开新会话:带上插件目录和超时;stdin 里是那句提示和贴的图;返回正文和 Claude Code 起的会话 id', async () => {
const { deps, calls, lookups } = commentDeps([{ lines: [init('new-1'), structured('按名字找文件。', 'new-1')] }])
it('开新会话:带上插件目录、按没动静计时;会话 id 由扩展生成、先记下再起进程;stdin 里是那句提示和贴的图;返回正文', async () => {
const { deps, calls, lookups } = commentDeps([{ lines: [init('claude-起的'), structured('按名字找文件。', 'claude-起的')] }])
const ask = comment({ profilePath: '/p/sonnet.json', images: [PNG] })
expect(await askCommentBody(ask, deps)).toEqual({ body: '按名字找文件。', sessionId: 'new-1' })
expect(await askCommentBody(ask, deps)).toEqual({ body: '按名字找文件。' })
expect(lookups).toEqual(['/work'])
expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', profilePath: '/p/sonnet.json' }))
expect(calls[0]).toMatchObject({ cwd: '/work', signal: ask.signal, timeoutMs: REWRITE_TIMEOUT_MS })
expect(ask.sessions).toHaveLength(1)
expect(ask.sessions[0]).toMatch(UUID)
expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', profilePath: '/p/sonnet.json', session: { id: ask.sessions[0], resume: false } }))
expect(calls[0]).toMatchObject({ cwd: '/work', signal: ask.signal, idleTimeoutMs: REWRITE_IDLE_TIMEOUT_MS })
expect(REWRITE_IDLE_TIMEOUT_MS).toBe(10 * 60_000)
expect(sent(calls[0])).toMatchObject({ text: '/ecc:writing-code-comments src/a.py:3-8', images: [{ source: { data: PNG.base64 } }] })
})
it('接着说:--resume 这块最近一次重写的会话,发块现在在哪和用户的话', async () => {
const { deps, calls } = commentDeps([{ lines: [structured('短一点。', 's0')] }])
expect(await askCommentBody(comment({ followUp: { sessionId: 's0', prompt: 'src/a.py:3-10\n\n短一点' } }), deps)).toEqual({ body: '短一点。', sessionId: 's0' })
expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', resume: 's0' }))
expect(sent(calls[0]).text).toBe('src/a.py:3-10\n\n短一点')
it('开新会话:onSession 记好之前不起进程', async () => {
const { deps, calls } = commentDeps([{ lines: [structured('新的。')] }])
let saved: () => void = () => {}
const answer = askCommentBody(comment({
onSession: () => new Promise((resolve) => {
saved = resolve
}),
}), deps)
await settle()
expect(calls).toHaveLength(0)
saved()
expect(await answer).toEqual({ body: '新的。' })
expect(calls).toHaveLength(1)
})
it('那次会话找不到了:开新会话按技能命令重新来,图片照样带上', async () => {
it('接着说:--resume 这块最近一次重写的会话,发块现在在哪和用户的话,不再记会话', async () => {
const { deps, calls } = commentDeps([{ lines: [structured('短一点。', 's0')] }])
const ask = comment({ followUp: { sessionId: 's0', prompt: 'src/a.py:3-10\n\n短一点' } })
expect(await askCommentBody(ask, deps)).toEqual({ body: '短一点。' })
expect(calls[0].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', session: { id: 's0', resume: true } }))
expect(sent(calls[0]).text).toBe('src/a.py:3-10\n\n短一点')
expect(ask.sessions).toEqual([])
})
it('那次会话找不到了:生成新的会话 id、记下后按技能命令重新来,图片照样带上', async () => {
const { deps, calls } = commentDeps([
{ lines: [line({ type: 'result', subtype: 'error_during_execution', is_error: true, errors: ['No conversation found with session ID: s0'], session_id: 's0' })] },
{ lines: [structured('重新写的。', 'new-1')] },
])
const ask = comment({ prompt: '/ecc:writing-code-comments src/a.py:3-10\n\n短一点', images: [PNG], followUp: { sessionId: 's0', prompt: 'src/a.py:3-10\n\n短一点' } })
expect(await askCommentBody(ask, deps)).toEqual({ body: '重新写的。', sessionId: 'new-1' })
expect(calls[1].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc' }))
expect(await askCommentBody(ask, deps)).toEqual({ body: '重新写的。' })
expect(ask.sessions).toHaveLength(1)
expect(calls[1].args).toEqual(rewriteArgs({ pluginRoot: '/plugins/ecc', session: { id: ask.sessions[0], resume: false } }))
expect(sent(calls[1])).toMatchObject({ text: '/ecc:writing-code-comments src/a.py:3-10\n\n短一点', images: [{ source: { data: PNG.base64 } }] })
})
it('只回了一段话、没交结构化结果:只有会话 id 没有正文;Claude Code 报错、没给结果:抛错,别的失败不重开', async () => {
expect(await askCommentBody(comment(), commentDeps([{ lines: [result('我改好了。', 'new-1')] }]).deps)).toEqual({ sessionId: 'new-1' })
it('只回了一段话、没交结构化结果:没有正文;Claude Code 报错、没给结果:抛错,别的失败不重开', async () => {
expect(await askCommentBody(comment(), commentDeps([{ lines: [result('我改好了。', 'new-1')] }]).deps)).toEqual({})
await expect(askCommentBody(comment(), commentDeps([{ lines: [line({ type: 'result', is_error: true, result: '额度用完了' })] }]).deps)).rejects.toThrow('额度用完了')
await expect(askCommentBody(comment(), commentDeps([{ lines: [] }]).deps)).rejects.toThrow('没给出结果')
const timeout = commentDeps([{ lines: [], error: new ClaudeTimeoutError('超时', '') }, { lines: [structured('不该到这')] }])
await expect(askCommentBody(comment({ followUp: { sessionId: 's0', prompt: 'p' } }), timeout.deps)).rejects.toBeInstanceOf(ClaudeTimeoutError)
const ask = comment({ followUp: { sessionId: 's0', prompt: 'p' } })
await expect(askCommentBody(ask, timeout.deps)).rejects.toBeInstanceOf(ClaudeTimeoutError)
expect(timeout.calls).toHaveLength(1)
expect(ask.sessions).toEqual([])
})
it('等太久停掉时说的是没写完', () => {
expect(rewriteFailure(new ClaudeTimeoutError('超时', ''))).toBe(`等了 ${REWRITE_TIMEOUT_MS / 60_000} 分钟还没写完,已经停掉。`)
it('太久没动静停掉时说清等了多久、能接着写', () => {
expect(rewriteFailure(new ClaudeTimeoutError('超时', ''))).toBe('连续 10 分钟没有任何动静,已经停掉。接着说「继续」会从停下的地方接着写。')
expect(rewriteFailure(new ClaudeError('Claude 退出码非零 (1): 网络断了', ''))).toBe('Claude Code 没答成:Claude 退出码非零 (1): 网络断了')
})
})
+81 -53
View File
@@ -6,6 +6,7 @@
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { CommentEvent } from '../commentRewrite'
import type { AnswerEvent } from './answerStream'
import { randomUUID } from 'node:crypto'
import { buildStreamJsonLine, CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError, runClaudeLines } from '../../cc/spawnClaude'
import { COMMENT_PLUGIN, COMMENT_SCHEMA, createCommentParser } from '../commentRewrite'
import { createAnswerParser } from './answerStream'
@@ -13,10 +14,11 @@ import { createAnswerParser } from './answerStream'
/** 只给读、搜文件的工具。 */
const READ_ONLY_TOOLS = 'Read,Grep,Glob'
export const ASK_TIMEOUT_MS = 5 * 60_000
/** 问答只用读、搜的工具,一调就回,连续这么久一行输出都没有就是卡住了;一直有动静就一直等。 */
export const ASK_IDLE_TIMEOUT_MS = 5 * 60_000
/** 重写注释要读代码、画原型图、导出图片,比问答慢 */
export const REWRITE_TIMEOUT_MS = 15 * 60_000
/** 重写注释还会跑 OpenPencil 的 op 画图、导出,一条命令能好几分钟不出声(Claude Code 的 Bash 一条最多等 10 分钟),所以比问答多等一会儿。 */
export const REWRITE_IDLE_TIMEOUT_MS = 10 * 60_000
/** OpenPencil 装在应用里的命令行(写注释的技能用它画前端组件的原型图) */
export const OPENPENCIL_CLI = '/Applications/OpenPencil.app/Contents/MacOS/op'
@@ -24,14 +26,25 @@ export const OPENPENCIL_CLI = '/Applications/OpenPencil.app/Contents/MacOS/op'
/** 写注释的技能存原型的目录,`*` 是功能名(`my-apps`)。权限规则里写成 `Edit(/…)`,开头的 `/` 指 claude 的工作目录(仓库根目录),不是磁盘根目录。 */
const PROTOTYPE_DIR = 'docs/prototype/*/openpencil'
/** 这一轮用的会话:`resume` 时接着 `id` 那次会话说,否则开一个会话 id 就是 `id` 的新会话(id 由扩展定,见 [inSession])。 */
export interface ClaudeSession {
id: string
resume: boolean
}
/** 让 claude 用 `session` 这个会话的参数:接着说用 `--resume`,开新会话用 `--session-id`。 */
function sessionArgs(session: ClaudeSession): string[] {
return [session.resume ? '--resume' : '--session-id', session.id]
}
/**
* 一次提问的命令行参数;问题连同贴的图片从 stdin 按 stream-json 发(见 askClaude),没贴图也一样。只读靠三层:可用的内置工具只剩读、搜(`--tools`);这几个不用再批(`--allowedTools`),
* 别的一律拒绝、不等人批(`--permission-mode dontAsk`,盖过 profile 里的 bypassPermissions);
* 不加载任何 MCP 服务(`--strict-mcp-config` 又不给 `--mcp-config`),免得 MCP 工具能改东西。
* addDirs 是工作目录以外还能读的目录(`--add-dir`);给了 schema 就按它交结构化结果(`--json-schema`)。
* addDirs 是工作目录以外还能读的目录(`--add-dir`);给了 schema 就按它交结构化结果(`--json-schema`);session 是这次用的会话(见 [ClaudeSession])。
*/
export function askArgs(options: { profilePath?: string, resume?: string, schema?: string, addDirs?: readonly string[] } = {}): string[] {
const { profilePath, resume, schema, addDirs = [] } = options
export function askArgs(options: { profilePath?: string, session: ClaudeSession, schema?: string, addDirs?: readonly string[] }): string[] {
const { profilePath, session, schema, addDirs = [] } = options
return [
...(profilePath ? ['--settings', profilePath] : []),
'--tools',
@@ -42,7 +55,7 @@ export function askArgs(options: { profilePath?: string, resume?: string, schema
'dontAsk',
'--strict-mcp-config',
...(addDirs.length > 0 ? ['--add-dir', ...addDirs] : []),
...(resume ? ['--resume', resume] : []),
...sessionArgs(session),
'-p',
'--input-format',
'stream-json',
@@ -60,10 +73,10 @@ export function askArgs(options: { profilePath?: string, resume?: string, schema
* 规则里的 `*` 能跨空格,`mkdir -p docs/prototype/x/openpencil/exports src/y` 也对得上,所以建目录时多跟一个路径的另外拒掉(`--disallowedTools`)。
* `--restricted` 不读用户、项目的设置文件,那里放行的命令(比如 `git -C`)进不来,文件工具也只认工作目录;
* 写注释的插件因此要单独加载(`--plugin-dir`),它的目录加进工作目录(`--add-dir`),技能才读得到它的参考文档。
* 有 resume 就接着那次会话说,没有就开新会话,会话 id 由 Claude Code 起。
* session 是这次用的会话(见 [ClaudeSession])。
*/
export function rewriteArgs(options: { pluginRoot: string, profilePath?: string, resume?: string }): string[] {
const { pluginRoot, profilePath, resume } = options
export function rewriteArgs(options: { pluginRoot: string, profilePath?: string, session: ClaudeSession }): string[] {
const { pluginRoot, profilePath, session } = options
const allowed = [READ_ONLY_TOOLS, `Bash(${OPENPENCIL_CLI} *)`, `Bash(mkdir -p ${PROTOTYPE_DIR}/exports)`, `Edit(/${PROTOTYPE_DIR}/**)`].join(',')
return [
...(profilePath ? ['--settings', profilePath] : []),
@@ -81,7 +94,7 @@ export function rewriteArgs(options: { pluginRoot: string, profilePath?: string,
'--permission-mode',
'dontAsk',
'--strict-mcp-config',
...(resume ? ['--resume', resume] : []),
...sessionArgs(session),
'-p',
'--input-format',
'stream-json',
@@ -103,19 +116,20 @@ export interface AskRun {
profilePath?: string
/** 追问:接着 sessionId 那次会话问 prompt;会话找不到了(过期、被清理)就按完整问题重新开一次 */
followUp?: { sessionId: string, prompt: string }
/** 开新会话前先把扩展给它定的会话 id 交给这里记下,记好了才起进程,所以这一轮没答完也接得上 */
onSession: (sessionId: string) => Promise<void>
cwd: string
/** cwd 以外还要读的目录(别的工作区文件夹、问的工作区外的库文件所在的目录) */
addDirs?: string[]
/** 按这个 JSON Schema 交结构化结果(`--json-schema`);只要一段话回答时没有 */
schema?: string
signal: AbortSignal
/** 答的过程中:会话 id、回答长出来的字、正在读什么 */
/** 答的过程中:回答长出来的字、正在读什么 */
onEvent: (event: Exclude<AnswerEvent, { kind: 'result' | 'error' }>) => void
}
export interface Answer {
text: string
sessionId: string
/** 要了结构化结果时交回的结构;没要、没交时没有 */
output?: unknown
}
@@ -133,24 +147,47 @@ function missingSession(err: unknown): boolean {
}
/**
* 问一次,答完返回最终回答(要了结构化结果的连同交回的结构;只交了结构、没说话也算答了);
* 失败、超时、被叫停时抛 ClaudeError(及其子类),用 askFailure 换成给人看的话。
* 跑一轮对话:有 `followUp` 就接着那次会话发它的话,没有、或那次会话找不到了,就开新会话发完整的 `prompt`。
*
* 新会话的 id 由扩展当场生成,先等 `onSession` 记下再起进程:进程刚起来就挂了、超时了、被叫停了,下次也能接着这个会话说。
*
* @param chat 完整的话、接着说时的会话和话、记下新会话 id 的地方
* @param once 用给的会话发一次话、等它答完
*/
async function inSession<T>(chat: Pick<AskRun, 'prompt' | 'followUp' | 'onSession'>, once: (prompt: string, session: ClaudeSession) => Promise<T>): Promise<T> {
if (chat.followUp) {
try {
return await once(chat.followUp.prompt, { id: chat.followUp.sessionId, resume: true })
}
catch (err) {
if (!missingSession(err))
throw err
}
}
const id = randomUUID()
await chat.onSession(id)
return once(chat.prompt, { id, resume: false })
}
/**
* 问一次,答完返回最终回答(要了结构化结果的连同交回的结构;只交了结构、没说话也算答了);接着哪次会话问见 [inSession]。
* 失败、太久没动静、被叫停时抛 [ClaudeError](及其子类),用 [askFailure] 换成给人看的话。
*/
export async function askClaude(run: AskRun, deps: AskDeps = DEFAULT_DEPS): Promise<Answer> {
const once = async (prompt: string, resume?: string): Promise<Answer> => {
return inSession(run, async (prompt, session) => {
const parse = createAnswerParser()
let answer: Answer | undefined
let failure: string | undefined
await deps.runLines({
args: askArgs({ profilePath: run.profilePath, resume, schema: run.schema, addDirs: run.addDirs }),
args: askArgs({ profilePath: run.profilePath, session, schema: run.schema, addDirs: run.addDirs }),
input: buildStreamJsonLine(prompt, run.images),
cwd: run.cwd,
signal: run.signal,
timeoutMs: ASK_TIMEOUT_MS,
idleTimeoutMs: ASK_IDLE_TIMEOUT_MS,
onLine: (line) => {
for (const event of parse(line)) {
if (event.kind === 'result')
answer = { text: event.text, sessionId: event.sessionId, ...(event.output !== undefined && { output: event.output }) }
answer = { text: event.text, ...(event.output !== undefined && { output: event.output }) }
else if (event.kind === 'error')
failure = event.message
else
@@ -163,17 +200,7 @@ export async function askClaude(run: AskRun, deps: AskDeps = DEFAULT_DEPS): Prom
if (!answer || (!answer.text && answer.output === undefined))
throw new ClaudeError('Claude Code 结束了,但没给出回答', '')
return answer
}
if (run.followUp) {
try {
return await once(run.followUp.prompt, run.followUp.sessionId)
}
catch (err) {
if (!missingSession(err))
throw err
}
}
return once(run.prompt)
})
}
export interface CommentAsk {
@@ -184,16 +211,17 @@ export interface CommentAsk {
profilePath?: string
/** 接着 sessionId 那次重写的会话发 prompt(块现在在哪和用户的话);会话找不到了就按上面的 prompt 开新会话 */
followUp?: { sessionId: string, prompt: string }
/** 开新会话前先把扩展给它定的会话 id 交给这里记下,记好了才起进程,所以这一轮没写完也接得上 */
onSession: (sessionId: string) => Promise<void>
cwd: string
signal: AbortSignal
/** 重写的过程中:正文写出来的字、正在做什么 */
onEvent: (event: Extract<CommentEvent, { kind: 'text' | 'activity' }>) => void
}
/** 重写一次的结果:结构化结果里的注释正文(只回了一段话时没有),这次的会话 id。 */
/** 重写一次的结果:结构化结果里的注释正文,只回了一段话时没有。 */
export interface CommentAnswer {
body?: string
sessionId: string
}
export interface CommentAskDeps extends AskDeps {
@@ -207,25 +235,25 @@ const COMMENT_DEPS: CommentAskDeps = {
}
/**
* 「重写注释」问一次:参数见 rewriteArgs,按 COMMENT_SCHEMA 交结构化结果,写回归扩展。有 followUp 就接着那次会话说,会话找不到了按 prompt 开新会话。
* 返回结果里的注释正文和会话 id;只回了一段话、没交结构化结果时没有正文。失败、超时、被叫停时抛 ClaudeError(及其子类),用 rewriteFailure 换成给人看的话。
* 「重写注释」问一次:参数见 [rewriteArgs],按 [COMMENT_SCHEMA] 交结构化结果,写回归扩展;接着哪次会话说见 [inSession]。
* 返回结果里的注释正文;只回了一段话、没交结构化结果时没有正文。失败、太久没动静、被叫停时抛 [ClaudeError](及其子类),用 [rewriteFailure] 换成给人看的话。
*/
export async function askCommentBody(ask: CommentAsk, deps: CommentAskDeps = COMMENT_DEPS): Promise<CommentAnswer> {
const pluginRoot = await deps.pluginRoot(ask.cwd)
const once = async (prompt: string, resume?: string): Promise<CommentAnswer> => {
return inSession(ask, async (prompt, session) => {
const parse = createCommentParser()
let result: CommentAnswer | undefined
let failure: string | undefined
await deps.runLines({
args: rewriteArgs({ pluginRoot, profilePath: ask.profilePath, resume }),
args: rewriteArgs({ pluginRoot, profilePath: ask.profilePath, session }),
input: buildStreamJsonLine(prompt, ask.images),
cwd: ask.cwd,
signal: ask.signal,
timeoutMs: REWRITE_TIMEOUT_MS,
idleTimeoutMs: REWRITE_IDLE_TIMEOUT_MS,
onLine: (line) => {
for (const event of parse(line)) {
if (event.kind === 'result')
result = { ...(event.body !== undefined && { body: event.body }), sessionId: event.sessionId }
result = event.body !== undefined ? { body: event.body } : {}
else if (event.kind === 'error')
failure = event.message
else
@@ -238,17 +266,7 @@ export async function askCommentBody(ask: CommentAsk, deps: CommentAskDeps = COM
if (!result)
throw new ClaudeError('Claude Code 结束了,但没给出结果', '')
return result
}
if (ask.followUp) {
try {
return await once(ask.followUp.prompt, ask.followUp.sessionId)
}
catch (err) {
if (!missingSession(err))
throw err
}
}
return once(ask.prompt)
})
}
/** `claude plugin list --json` 里启用着的 name 插件的目录;没装、没启用、读不出来时没有。 */
@@ -273,7 +291,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
/** 写注释的技能所在插件的目录:在 cwd 里问 Claude Code 装了哪些插件。没找到时抛错,说清楚要先装上。 */
export async function commentPluginRoot(cwd: string, runLines: typeof runClaudeLines = runClaudeLines): Promise<string> {
const lines: string[] = []
await runLines({ args: ['plugin', 'list', '--json'], cwd, onLine: line => lines.push(line), timeoutMs: 30_000 })
await runLines({ args: ['plugin', 'list', '--json'], cwd, onLine: line => lines.push(line), idleTimeoutMs: 30_000 })
const root = pluginRootIn(lines.join('\n'), COMMENT_PLUGIN)
if (!root)
throw new ClaudeError(`没找到启用着的 ${COMMENT_PLUGIN} 插件,写注释的技能在它里面:在 Claude Code 里装上并启用后再重写。`, '')
@@ -285,7 +303,7 @@ const AUTH_HINT = /\/login|log ?in|auth|401|403|api key|unauthori[sz]ed|credenti
/** 问失败了,给人看的一句话。 */
export function askFailure(err: unknown): string {
if (err instanceof ClaudeTimeoutError)
return `等了 ${Math.round(ASK_TIMEOUT_MS / 60_000)} 分钟还没答完,已经停掉。可以把问题问得具体一点再试。`
return idleFailure(ASK_IDLE_TIMEOUT_MS, '做')
if (err instanceof ClaudeError && err.message === CLAUDE_MISSING_MSG)
return '没找到 Claude Code(claude 命令)。装好、能在终端里运行 claude 之后再问。'
const message = err instanceof Error ? err.message : String(err)
@@ -294,9 +312,19 @@ export function askFailure(err: unknown): string {
return `Claude Code 没答成:${message}`
}
/** 重写注释失败了,给人看的一句话:超时说的是没写完,别的同 askFailure。 */
/** 重写注释失败了,给人看的一句话:太久没动静时按重写注释等的时长说,别的同 [askFailure]。 */
export function rewriteFailure(err: unknown): string {
if (err instanceof ClaudeTimeoutError)
return `等了 ${Math.round(REWRITE_TIMEOUT_MS / 60_000)} 分钟还没写完,已经停掉。`
return idleFailure(REWRITE_IDLE_TIMEOUT_MS, '写')
return askFailure(err)
}
/**
* 太久没动静被停掉时给人看的话:说清等了多久,并告诉人这一轮的会话记着,说「继续」就接着干。
*
* @param idleTimeoutMs 等了多久没动静(毫秒)
* @param doing 接着干的是什么(`做`、`写`)
*/
function idleFailure(idleTimeoutMs: number, doing: string): string {
return `连续 ${Math.round(idleTimeoutMs / 60_000)} 分钟没有任何动静,已经停掉。接着说「继续」会从停下的地方接着${doing}。`
}
+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)
})
})
+29 -17
View File
@@ -1,12 +1,12 @@
import type { Event, ExtensionContext, Memento } from 'vscode'
import type { ClaudeProfile } from '../../cc/profiles'
import type { ClaudeImage } from '../../cc/spawnClaude'
import type { ChatKind, QaEntryView, QaMessage } from '../editor/messages'
import type { ChatKind, QaEntryView, QaMessage, QaThreadView } from '../editor/messages'
import type { PathSubject, TopSubject } from '../editor/pathPreview'
import type { PathLocation, QaLocation } from '../folderStore'
import type { AskEnd, RunningAsk } from './asks'
import type { Answer, AskRunner, CommentAnswer, CommentAsk } from './claudeAsk'
import type { ChatEntry, QaEntry, RewriteEntry } from './qaMap'
import type { ChatEntry, QaEntry, QaThread, RewriteEntry } from './qaMap'
import { randomUUID } from 'node:crypto'
import { Disposable, EventEmitter, Uri, window } from 'vscode'
import { getSettings, onDidChangeSettings } from '../../settings/store'
@@ -17,9 +17,9 @@ import { QaAsks, REWRITE_POLICY } from './asks'
import { askClaude, askCommentBody } from './claudeAsk'
import { describePath } from './pathAskDescribe'
import { pathAskFollowUp, pathAskPrompt } from './pathAskPrompt'
import { parsePathQa, PATH_ASK_ANCHOR, PATH_QA_STATE_KEY, pathQaEntries, withoutPathQa, withoutPathQaKey, withPathQa } from './pathQa'
import { parsePathQa, PATH_ASK_ANCHOR, PATH_QA_STATE_KEY, pathQaEntries, pathQaSession, withoutPathQa, withoutPathQaKey, withPathQa, withPathSession } from './pathQa'
import { isQaImageType, QA_IMAGE_DIR, removeQaImages, writeQaImages } from './qaImages'
import { latestSession, QA_FILE, REWRITE_FILE, withEntry, withoutEntry } from './qaMap'
import { latestSession, QA_FILE, REWRITE_FILE, withEntry, withoutEntry, withSession } from './qaMap'
/** 问 AI 开不了:问答记在工作区文件夹里,一个都没打开时没地方存。 */
const NO_QA_HOME = '没打开工作区文件夹,问答没地方存。'
@@ -97,14 +97,13 @@ function viewEntry(root: Uri | undefined, imageUrl: (file: Uri) => string, entry
}
/** 答完的一轮共有的几项:起 id、把带的图片写进 `<rootPath>/.spx/code-qa/`。图片写不进时抛错。 */
async function chatEntry(rootPath: string, question: string, sessionId: string, profile: ClaudeProfile | undefined, images: readonly ClaudeImage[]): Promise<ChatEntry> {
async function chatEntry(rootPath: string, question: string, profile: ClaudeProfile | undefined, images: readonly ClaudeImage[]): Promise<ChatEntry> {
const id = randomUUID()
const names = await writeQaImages(rootPath, id, images)
return {
id,
question,
createdAt: new Date().toISOString(),
sessionId,
...(profile && { profile: profile.name }),
...(names.length > 0 && { images: names }),
}
@@ -115,6 +114,11 @@ function outsideDirs(at: QaLocation): { addDirs?: string[] } {
return at.outsideDir === undefined ? {} : { addDirs: [at.outsideDir] }
}
/** 发给预览的各块记录:只画有记录的块,只记着会话、一次都还没记下的块没东西可画。 */
function threadViews<E extends ChatEntry>(threads: readonly QaThread<E>[], show: (entry: E) => QaEntryView): QaThreadView[] {
return threads.flatMap(({ anchor, entries }) => entries.length > 0 ? [{ anchor, entries: entries.map(show) }] : [])
}
/**
* 块下面的对话:问 AI 和重写注释,各自记在各工作区文件夹的一份文件里(`.spx/code-qa.json`、`.spx/comment-rewrites.json`;
* 改名、删文件、外部改动的跟随同文件注释,见 FolderStore),挂在块的位置记号上,各自接着自己最近一次的会话。
@@ -197,10 +201,10 @@ export class CodeQa implements Disposable {
available: top !== undefined,
rewritable: rewriteAt !== undefined,
subject,
threads: threads.map(({ anchor, entries }) => ({ anchor, entries: entries.map(show) })),
threads: threadViews(threads, show),
path: pathEntries.map(show),
current: this.asks.currentFor(uri.toString()),
rewrites: rewrites.map(({ anchor, entries }) => ({ anchor, entries: entries.map(showRewrite) })),
rewrites: threadViews(rewrites, showRewrite),
rewriteCurrent: this.rewrites.currentFor(uri.toString()),
}
}
@@ -208,7 +212,8 @@ export class CodeQa implements Disposable {
/**
* 开始问;问不了时返回原因(给人看的话)。在问答记着的工作区文件夹里跑 claude(见 [locateQa]),它读得到整个项目;
* 问的是工作区外的库文件时另外放行读库文件所在的目录。
* 这块问过的就接着最近一次的会话问(收起再展开、重启 VS Code 都一样),没问过就带上代码上下文开新会话。
* 这块开过会话就接着最近一次的会话问(没答完就超时、出错、被叫停的那次也算;收起再展开、重启 VS Code 都一样),没开过就带上代码上下文开新会话,
* 新会话的 id 起进程前就记在这块上(见 [withSession])。
* 用问 AI 眼下选的 profile;和那次会话开头用的不一样也照样接着问(`--resume` 和 `--settings` 能一起用)。
* 带的图片随问题发,答成了才写进 `.spx/code-qa/`、记在这条上;没答成不留文件。
*/
@@ -224,6 +229,7 @@ export class CodeQa implements Disposable {
prompt,
images,
...(sessionId && { followUp: { sessionId, prompt: followUp } }),
onSession: (session: string) => this.store.update(at.folder, map => withSession(map, at.key, anchor, session)),
...(profile && { profilePath: profile.path }),
cwd: at.folder.uri.fsPath,
...outsideDirs(at),
@@ -237,7 +243,7 @@ export class CodeQa implements Disposable {
}
let entry: QaEntry
try {
const { id, question: asked, ...rest } = await chatEntry(at.folder.uri.fsPath, question, end.answer.sessionId, profile, images)
const { id, question: asked, ...rest } = await chatEntry(at.folder.uri.fsPath, question, profile, images)
entry = { id, question: asked, answer: end.answer.text, ...rest }
}
catch (err) {
@@ -253,8 +259,9 @@ export class CodeQa implements Disposable {
/**
* 重写一段注释;开不了时返回原因(给人看的话)。在工作区文件夹里跑 claude(见 [askCommentBody]),用重写注释眼下选的 profile。
* 带 followUp 时接着这块最近一次重写的会话说(和问答的会话分开),没重写过就开新会话;不带时(点按钮)总是开新会话。
* 模型答完先写回文件,再把这一轮记下:用户的话、交回的正文、没写进文件的原因、会话 id、profile、图片。Claude Code 报错、被叫停的不记,文件不动。
* 带 followUp 时接着这块最近一次重写的会话说(和问答的会话分开,没写完就停掉的那次也算),没重写过就开新会话;不带时(点按钮)总是开新会话。
* 新会话的 id 起进程前就记在这块上。模型答完先写回文件,再把这一轮记下:用户的话、交回的正文、没写进文件的原因、profile、图片。
* Claude Code 报错、太久没动静、被叫停的这一轮不记,文件不动,只有会话记着能接着说。
*/
async rewrite(uri: Uri, request: RewriteRequest): Promise<string | undefined> {
const ready = await this.prepare(locate(uri), request.images, 'rewrite')
@@ -268,6 +275,7 @@ export class CodeQa implements Disposable {
prompt,
images,
...(sessionId && followUp !== undefined && { followUp: { sessionId, prompt: followUp } }),
onSession: (session: string) => this.rewriteStore.update(at.folder, map => withSession(map, at.key, anchor, session)),
...(profile && { profilePath: profile.path }),
cwd: at.folder.uri.fsPath,
}
@@ -282,7 +290,7 @@ export class CodeQa implements Disposable {
const error = await writeBack(body)
let entry: RewriteEntry
try {
const { id, question: said, ...rest } = await chatEntry(at.folder.uri.fsPath, question, end.answer.sessionId, profile, images)
const { id, question: said, ...rest } = await chatEntry(at.folder.uri.fsPath, question, profile, images)
entry = { id, question: said, ...(body !== undefined && { body }), ...(error !== undefined && { error }), ...rest }
}
catch (err) {
@@ -350,8 +358,9 @@ export class CodeQa implements Disposable {
/**
* 在 subject 这一页的顶部问一轮(文件、文件夹、「了解一块代码」的功能,记在哪见 [topQaLocation]),用问 AI 眼下选的 profile;问不了、进行中再问时返回给人看的原因。
* 问过的接着最近一次的会话说 run.followUp,没问过、会话找不到了发 run.prompt;要结构化结果时带上 run.schema,别的目录要读时带上 run.addDirs。
* 答完交给 settle:它给出这一轮记下的回答,或说交回的不合格(这一轮算没答成)。答成了记在 workspaceState 里,带的图片写进 `.spx/code-qa/`;没答成不记。
* 开过会话的接着最近一次的会话说 run.followUp(没答成的那次也算),没开过、会话找不到了发 run.prompt,新会话的 id 起进程前就记下;
* 要结构化结果时带上 run.schema,别的目录要读时带上 run.addDirs。
* 答完交给 settle:它给出这一轮记下的回答,或说交回的不合格(这一轮算没答成)。答成了记在 workspaceState 里,带的图片写进 `.spx/code-qa/`;没答成的这一轮不记,只有会话记着。
*/
async askTop(subject: Uri, request: TopRequest, run: TopRun, settle: (answer: Answer) => Promise<{ text: string } | { error: string }>): Promise<string | undefined> {
const top = topQaLocation(subject)
@@ -368,7 +377,7 @@ export class CodeQa implements Disposable {
return err instanceof Error ? err.message : String(err)
}
const owner = subject.toString()
const sessionId = pathQaEntries(this.pathMap(), top.key).at(-1)?.sessionId
const sessionId = pathQaSession(this.pathMap(), top.key)
return this.asks.start({
owner,
askId,
@@ -378,6 +387,9 @@ export class CodeQa implements Disposable {
prompt: run.prompt,
images,
...(sessionId && { followUp: { sessionId, prompt: run.followUp } }),
onSession: session => this.enqueuePath(async () => {
await this.paths.update(PATH_QA_STATE_KEY, withPathSession(this.pathMap(), top.key, session))
}),
...(profile && { profilePath: profile.path }),
cwd: top.folder.uri.fsPath,
...(run.schema !== undefined && { schema: run.schema }),
@@ -398,7 +410,7 @@ export class CodeQa implements Disposable {
}
let entry: QaEntry
try {
const { id, question: asked, ...rest } = await chatEntry(top.folder.uri.fsPath, question, end.answer.sessionId, profile, images)
const { id, question: asked, ...rest } = await chatEntry(top.folder.uri.fsPath, question, profile, images)
entry = { id, question: asked, answer: settled.text, ...rest }
}
catch (err) {
+29 -2
View File
@@ -1,9 +1,9 @@
import type { QaEntry } from './qaMap'
import { describe, expect, it } from 'vitest'
import { parsePathQa, pathQaEntries, pathQaKey, relativeQaKey, withoutPathQa, withPathQa } from './pathQa'
import { parsePathQa, pathQaEntries, pathQaKey, pathQaSession, relativeQaKey, withoutPathQa, withoutPathQaKey, withPathQa, withPathSession } from './pathQa'
function entry(id: string): QaEntry {
return { id, question: `问 ${id}`, answer: `答 ${id}`, createdAt: '2026-10-02T00:00:00.000Z', sessionId: `s-${id}` }
return { id, question: `问 ${id}`, answer: `答 ${id}`, createdAt: '2026-10-02T00:00:00.000Z' }
}
const FOLDER = 'file:///work/app'
@@ -25,6 +25,33 @@ describe('顶部问答按路径记', () => {
expect(pathQaEntries(stored, DIR).map(item => item.id)).toEqual(['d'])
})
it('会话单记:开新会话前记下,后开的盖掉先前的,记问答不动它;一次都没答成也留着、读得回来', () => {
let map = withPathSession({}, FILE, 's-1')
expect(pathQaSession(map, FILE)).toBe('s-1')
expect(pathQaEntries(map, FILE)).toEqual([])
map = withPathQa(map, FILE, entry('1'))
expect(pathQaSession(map, FILE)).toBe('s-1')
map = withPathSession(map, FILE, 's-2')
expect(pathQaSession(map, FILE)).toBe('s-2')
expect(pathQaEntries(map, FILE).map(item => item.id)).toEqual(['1'])
expect(pathQaSession(map, DIR)).toBeUndefined()
const again = parsePathQa(JSON.parse(JSON.stringify({ ...map, [DIR]: { entries: [], session: 's-dir' } })))
expect(again).toEqual({ [FILE]: { entries: [entry('1')], session: 's-2' }, [DIR]: { entries: [], session: 's-dir' } })
})
it('删掉一条会话照旧接着;问光了、整个删掉时会话也不接了', () => {
const map = withPathSession(withPathQa(withPathQa({}, FILE, entry('1')), FILE, entry('2')), FILE, 's-1')
const once = withoutPathQa(map, FILE, '2')
expect(pathQaSession(once, FILE)).toBe('s-1')
expect(withoutPathQa(once, FILE, '1')).toEqual({})
expect(withoutPathQaKey(map, FILE)).toEqual({})
})
it('旧版本存的是光一串问答:读进来时接着最后一条的会话 id', () => {
const old = { [FILE]: [{ ...entry('1'), sessionId: 's-old' }, { ...entry('2'), sessionId: 's-last' }], [DIR]: [{ ...entry('d'), sessionId: '' }] }
expect(parsePathQa(old)).toEqual({ [FILE]: { entries: [entry('1'), entry('2')], session: 's-last' }, [DIR]: { entries: [entry('d')] } })
})
it('工作区根是空相对路径,走不出去的路径没有键', () => {
expect(relativeQaKey('/work/app', '/work/app')).toBe('')
expect(relativeQaKey('/work/app', '/work/app/src/main.ts')).toBe('src/main.ts')
+56 -26
View File
@@ -1,11 +1,12 @@
/**
* 预览顶部的问答:文件、文件夹按「工作区文件夹 + 相对路径」,「了解一块代码」的功能对话页按功能的 id,记在这个窗口的 workspaceState 里。
* 不写进按块存的 `.spx/code-qa.json`。
* 预览顶部的问答:文件、文件夹按「工作区文件夹 + 相对路径」,「了解一块代码」的功能对话页按功能的 id,记在这个窗口的 workspaceState 里,
* 每处连同下一次接着说时接的会话。不写进按块存的 `.spx/code-qa.json`。
*/
import type { QaEntry } from './qaMap'
import * as path from 'node:path'
import { normalizeKey } from '../pathMap'
import { legacySession } from './qaMap'
/** workspaceState 里这份表的键 */
export const PATH_QA_STATE_KEY = 'codeReader.pathQa'
@@ -16,8 +17,16 @@ export const PATH_QA_STATE_KEY = 'codeReader.pathQa'
*/
export const PATH_ASK_ANCHOR = '\0path'
/** 工作区文件夹 Uri → 那条路径上的问答(按问的先后) */
export type PathQaMap = Record<string, QaEntry[]>
/** 一处顶部的问答,和这里下一次接着说时接的会话。 */
export interface PathQa {
/** 按问的先后 */
entries: QaEntry[]
/** 这里最近一次开的 Claude Code 会话 id,开新会话前就记下,没答完的那一轮也算;没开过时没有 */
session?: string
}
/** 记录的键(见 [pathQaKey]、[featureQaKey])→ 那里的问答 */
export type PathQaMap = Record<string, PathQa>
/** 工作区根的相对路径是空字符串,跟任何一个文件或子目录都不同。 */
export function relativeQaKey(rootPath: string, targetPath: string): string | undefined {
@@ -43,26 +52,33 @@ export function displayRelativePath(relativePath: string): string {
}
export function pathQaEntries(map: PathQaMap, key: string): QaEntry[] {
return map[key] ?? []
return map[key]?.entries ?? []
}
/** 新的一次问答接在这条路径已有的后面,别的路径不动。 */
/** key 那里接着说时接着的会话:最近一次开的,答没答完都算;没开过时没有,开新会话。 */
export function pathQaSession(map: PathQaMap, key: string): string | undefined {
return map[key]?.session
}
/** 新的一次问答接在这条路径已有的后面,会话和别的路径不动。 */
export function withPathQa(map: PathQaMap, key: string, entry: QaEntry): PathQaMap {
return { ...map, [key]: [...pathQaEntries(map, key), entry] }
return { ...map, [key]: { ...map[key], entries: [...pathQaEntries(map, key), entry] } }
}
/** 删掉这一次;这条路径问光了就把键拿掉。没有这一次时原样返回。 */
/** 记下 key 那里接着说时要接的会话:开新会话前记,问答不动。 */
export function withPathSession(map: PathQaMap, key: string, session: string): PathQaMap {
return { ...map, [key]: { entries: pathQaEntries(map, key), session } }
}
/** 删掉这一次;这条路径问光了就把键拿掉,会话也不接了(下次从头问)。没有这一次时原样返回。 */
export function withoutPathQa(map: PathQaMap, key: string, entryId: string): PathQaMap {
const entries = pathQaEntries(map, key)
const kept = entries.filter(entry => entry.id !== entryId)
if (kept.length === entries.length)
return map
if (kept.length === 0) {
const next = { ...map }
delete next[key]
return next
}
return { ...map, [key]: kept }
if (kept.length === 0)
return withoutPathQaKey(map, key)
return { ...map, [key]: { ...map[key], entries: kept } }
}
/** 去掉这个键下的全部问答;没有这个键时原样返回。 */
@@ -79,26 +95,40 @@ export function parsePathQa(value: unknown): PathQaMap {
if (!isRecord(value))
return {}
const map: PathQaMap = {}
for (const [key, entries] of Object.entries(value)) {
if (!Array.isArray(entries))
continue
const parsed = entries.flatMap((entry) => {
const one = parseEntry(entry)
return one ? [one] : []
})
if (parsed.length > 0)
map[key] = parsed
for (const [key, stored] of Object.entries(value)) {
const one = parsePlace(stored)
if (one)
map[key] = one
}
return map
}
/** 一处的问答和会话;既没问答又没会话、认不出时没有。旧版本存的是光一串问答,会话照 [legacySession] 补上。 */
function parsePlace(stored: unknown): PathQa | undefined {
let raw: unknown[]
let session: string | undefined
if (Array.isArray(stored)) {
raw = stored
session = legacySession(stored)
}
else if (isRecord(stored) && Array.isArray(stored.entries)) {
raw = stored.entries
session = typeof stored.session === 'string' && stored.session !== '' ? stored.session : undefined
}
else {
return undefined
}
const entries = raw.flatMap(entry => parseEntry(entry) ?? [])
return entries.length > 0 || session ? { entries, ...(session && { session }) } : undefined
}
function parseEntry(value: unknown): QaEntry | undefined {
if (!isRecord(value))
return undefined
const { id, question, answer, createdAt, sessionId, profile, images } = value
if (typeof id !== 'string' || typeof question !== 'string' || typeof answer !== 'string' || typeof createdAt !== 'string' || typeof sessionId !== 'string')
const { id, question, answer, createdAt, profile, images } = value
if (typeof id !== 'string' || typeof question !== 'string' || typeof answer !== 'string' || typeof createdAt !== 'string')
return undefined
const entry: QaEntry = { id, question, answer, createdAt, sessionId }
const entry: QaEntry = { id, question, answer, createdAt }
if (typeof profile === 'string' && profile !== '')
entry.profile = profile
if (Array.isArray(images) && images.every(name => typeof name === 'string'))
+40 -14
View File
@@ -6,10 +6,10 @@ import * as path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { detachedFiles, dropEntries, moveEntries, readPathMap, removeFiles, writePathMap } from '../pathMap'
import { writeQaImages } from './qaImages'
import { latestSession, QA_FILE, REWRITE_FILE, withEntry, withoutEntry } from './qaMap'
import { latestSession, QA_FILE, REWRITE_FILE, withEntry, withoutEntry, withSession } from './qaMap'
function entry(id: string, createdAt: string, question = `问 ${id}`): QaEntry {
return { id, question, answer: `答 ${id}`, createdAt, sessionId: `s-${id}` }
return { id, question, answer: `答 ${id}`, createdAt }
}
const DISPLAY = 'class ReferralRelation > function display'
@@ -70,20 +70,46 @@ describe('记一次、删一次', () => {
})
describe('再问时接着哪个会话', () => {
it('接着这块最近一次问答的会话;别的块、没问过的块、没记会话 id 的都重新开', () => {
it('接着这块最近一次开的会话:开新会话前记下,后开的盖掉先前的;记问答不动会话;别的块、没开过的块重新开', () => {
let map: QaMap = {}
map = withEntry(map, 'a.py', IF, entry('new', '2026-09-26T10:00:00.000Z'))
map = withEntry(map, 'a.py', IF, entry('old', '2026-09-25T10:00:00.000Z'))
map = withEntry(map, 'a.py', DISPLAY, { ...entry('bare', '2026-09-26T11:00:00.000Z'), sessionId: '' })
expect(latestSession(map['a.py'], IF)).toBe('s-new')
map = withSession(map, 'a.py', IF, 's-1')
map = withEntry(map, 'a.py', IF, entry('a', '2026-09-25T10:00:00.000Z'))
expect(latestSession(map['a.py'], IF)).toBe('s-1')
map = withSession(map, 'a.py', IF, 's-2')
expect(latestSession(map['a.py'], IF)).toBe('s-2')
expect(map['a.py']).toEqual([{ anchor: IF, entries: [entry('a', '2026-09-25T10:00:00.000Z')], session: 's-2' }])
map = withEntry(map, 'a.py', DISPLAY, entry('b', '2026-09-26T11:00:00.000Z'))
expect(latestSession(map['a.py'], DISPLAY)).toBeUndefined()
expect(latestSession(map['a.py'], 'function gone')).toBeUndefined()
expect(latestSession(undefined, IF)).toBeUndefined()
})
it('删掉最近那次,就接着剩下里最近的', () => {
const map = withEntry(withEntry({}, 'a.py', IF, entry('a', '2026-09-25T10:00:00.000Z')), 'a.py', IF, entry('b', '2026-09-26T10:00:00.000Z'))
expect(latestSession(withoutEntry(map, 'a.py', IF, 'b')['a.py'], IF)).toBe('s-a')
it('一次都没答成的块也记着会话(第一轮就超时、出错、被叫停),读写都留着', async () => {
const map: QaMap = withSession({}, 'a.py', IF, 's-1')
expect(map).toEqual({ 'a.py': [{ anchor: IF, entries: [], session: 's-1' }] })
await writePathMap(root, QA_FILE, map)
expect(await readPathMap(root, QA_FILE)).toEqual(map)
})
it('删掉一条会话照旧接着;块下面删光了会话也不接了,下次从头问', () => {
const map = withSession(withEntry(withEntry({}, 'a.py', IF, entry('a', '2026-09-25T10:00:00.000Z')), 'a.py', IF, entry('b', '2026-09-26T10:00:00.000Z')), 'a.py', IF, 's-1')
const once = withoutEntry(map, 'a.py', IF, 'b')
expect(latestSession(once['a.py'], IF)).toBe('s-1')
expect(withoutEntry(once, 'a.py', IF, 'a')).toEqual({})
})
it('旧文件不单记会话:读进来时接着这块最后一条记录的会话 id,写回去条目上不再带会话 id', async () => {
await writeRaw({ 'a.py': [{ anchor: IF, entries: [
{ ...entry('a', '2026-09-25T10:00:00.000Z'), sessionId: 's-old' },
{ ...entry('b', '2026-09-26T10:00:00.000Z'), sessionId: 's-last' },
] }, { anchor: DISPLAY, entries: [{ ...entry('c', '2026-09-26T10:00:00.000Z'), sessionId: '' }] }] })
const map = await readPathMap(root, QA_FILE)
expect(map).toEqual({ 'a.py': [
{ anchor: DISPLAY, entries: [entry('c', '2026-09-26T10:00:00.000Z')] },
{ anchor: IF, entries: [entry('a', '2026-09-25T10:00:00.000Z'), entry('b', '2026-09-26T10:00:00.000Z')], session: 's-last' },
] })
await writePathMap(root, QA_FILE, map)
expect(await fsp.readFile(qaFile(), 'utf8')).not.toContain('sessionId')
})
})
@@ -110,8 +136,8 @@ describe('读写 .spx/code-qa.json', () => {
})
expect(await readPathMap(root, QA_FILE)).toEqual({
'a.py': [{ anchor: IF, entries: [entry('ok', '2026-09-25T10:00:00.000Z')] }],
// 没有会话 id 的照样认,追问时重新开会话
'c.py': [{ anchor: IF, entries: [{ id: 'y', question: 'Q', answer: 'A', createdAt: '2026-09-25T10:00:00.000Z', sessionId: '' }] }],
// 没记会话的照样认,追问时重新开会话
'c.py': [{ anchor: IF, entries: [{ id: 'y', question: 'Q', answer: 'A', createdAt: '2026-09-25T10:00:00.000Z' }] }],
})
})
@@ -211,11 +237,11 @@ describe('重写注释的记录:另存一份,和问答分开', () => {
const exists = (file: string): Promise<boolean> => fsp.access(file).then(() => true, () => false)
function rewrite(id: string, createdAt: string, extra: Partial<RewriteEntry> = {}): RewriteEntry {
return { id, question: '/ecc:writing-code-comments a.py:3-8', body: `正文 ${id}`, createdAt, sessionId: `r-${id}`, ...extra }
return { id, question: '/ecc:writing-code-comments a.py:3-8', body: `正文 ${id}`, createdAt, ...extra }
}
it('存在 .spx/comment-rewrites.json,不进问答那份;正文、没写进文件的原因跟着这条读得回来,接着最近一次重写的会话', async () => {
const map: RewriteMap = withEntry(withEntry({}, 'a.py', IF, rewrite('a', '2026-09-25T10:00:00.000Z')), 'a.py', IF, rewrite('b', '2026-09-26T10:00:00.000Z', { error: '这段注释的定界符认不出来,没有改文件。' }))
const map: RewriteMap = withSession(withEntry(withEntry({}, 'a.py', IF, rewrite('a', '2026-09-25T10:00:00.000Z')), 'a.py', IF, rewrite('b', '2026-09-26T10:00:00.000Z', { error: '这段注释的定界符认不出来,没有改文件。' })), 'a.py', IF, 'r-b')
await writePathMap(root, REWRITE_FILE, map)
expect(await exists(rewriteFile())).toBe(true)
expect(await exists(qaFile())).toBe(false)
+42 -22
View File
@@ -1,19 +1,19 @@
/**
* 块下面的对话怎么存:每个工作区文件夹里每种对话一份文件,读写的规矩同文件注释(见 pathMap)。
* 问 AI 记在 `.spx/code-qa.json`,重写注释记在 `.spx/comment-rewrites.json`:两份分开,各自接着自己最近一次的会话。
* 形状是 `{ "相对路径": [{ anchor, entries: [...] }] }`:一份文件里有记录的每一块一项(anchor 见 BlockAnchors),块下面的按时间排。
* 形状是 `{ "相对路径": [{ anchor, entries: [...], session }] }`:一份文件里有记录或开过会话的每一块一项(anchor 见 BlockAnchors),块下面的按时间排,
* session 是这块下一次接着说时接的会话。
* 带的图片是 `.spx/code-qa/` 里的文件,条目删了(删一条、删源文件)文件跟着删(见 attachments)。
*/
import type { PathMap, PathMapFile } from '../pathMap'
import { isQaImageName, QA_IMAGE_DIR } from './qaImages'
/** 块下面的一轮:说的话、什么时候(ISO 时间)、Claude Code 的会话 id。 */
/** 块下面的一轮:说的话、什么时候(ISO 时间)。 */
export interface ChatEntry {
id: string
question: string
createdAt: string
sessionId: string
/** 用哪个 profile 答的(profile 名);选「默认」时、以前存的没有 */
profile?: string
/** 带的图片:`.spx/code-qa/` 里的文件名(见 qaImages),按贴的先后;没带图片时没有 */
@@ -31,10 +31,12 @@ export interface RewriteEntry extends ChatEntry {
error?: string
}
/** 挂在一块上的记录。 */
/** 挂在一块上的记录,和这块下一次接着说时接的会话。 */
export interface QaThread<E extends ChatEntry = QaEntry> {
anchor: string
entries: E[]
/** 这块最近一次开的 Claude Code 会话 id,开新会话前就记下,没答完的那一轮也算;没开过时没有 */
session?: string
}
export type QaMap = PathMap<QaThread[]>
@@ -58,7 +60,6 @@ function parseChat(value: Record<string, unknown>): ChatEntry | undefined {
id,
question,
createdAt,
sessionId: typeof value.sessionId === 'string' ? value.sessionId : '',
...(profile && { profile }),
...(images.length > 0 && { images }),
}
@@ -87,17 +88,21 @@ function parseRewriteEntry(value: unknown): RewriteEntry | undefined {
return { id, question, ...(body && { body }), ...(error && { error }), ...rest }
}
/** 同一块的并成一项;块按记号排,一块下面的按时间排:写出来的顺序稳定,git diff 干净。 */
/** 同一块的并成一项,既没记录又没会话的块不留;块按记号排,一块下面的按时间排:写出来的顺序稳定,git diff 干净。 */
function tidy<E extends ChatEntry>(threads: QaThread<E>[]): QaThread<E>[] {
const byAnchor = new Map<string, E[]>()
for (const thread of threads)
byAnchor.set(thread.anchor, [...byAnchor.get(thread.anchor) ?? [], ...thread.entries])
return [...byAnchor]
.filter(([, entries]) => entries.length > 0)
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
.map(([anchor, entries]) => ({
anchor,
entries: [...entries].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
const byAnchor = new Map<string, QaThread<E>>()
for (const thread of threads) {
const before = byAnchor.get(thread.anchor)
// 后来的会话盖掉先前的;只添记录、没带会话的那一项不动会话
const session = thread.session ?? before?.session
byAnchor.set(thread.anchor, { anchor: thread.anchor, entries: [...before?.entries ?? [], ...thread.entries], ...(session && { session }) })
}
return [...byAnchor.values()]
.filter(thread => thread.entries.length > 0 || thread.session)
.sort((a, b) => a.anchor < b.anchor ? -1 : a.anchor > b.anchor ? 1 : 0)
.map(thread => ({
...thread,
entries: [...thread.entries].sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)),
}))
}
@@ -110,13 +115,20 @@ function threadsParser<E extends ChatEntry>(parseEntry: (value: unknown) => E |
const anchor = isRecord(item) ? text(item.anchor) : undefined
if (!anchor || !isRecord(item) || !Array.isArray(item.entries))
return []
return [{ anchor, entries: item.entries.flatMap(entry => parseEntry(entry) ?? []) }]
const session = text(item.session) ?? legacySession(item.entries)
return [{ anchor, entries: item.entries.flatMap(entry => parseEntry(entry) ?? []), ...(session && { session }) }]
})
const tidied = tidy(threads)
return tidied.length > 0 ? tidied : undefined
}
}
/** 旧文件不单记会话,接着说时接的是这块最后一条记录的会话 id:读旧文件时照它补上,写回去就是新的样子。 */
export function legacySession(entries: readonly unknown[]): string | undefined {
const last = entries.at(-1)
return isRecord(last) ? text(last.sessionId) : undefined
}
function threadsFile<E extends ChatEntry>(path: string, label: string, parseEntry: (value: unknown) => E | undefined): PathMapFile<QaThread<E>[]> {
return {
path,
@@ -135,17 +147,25 @@ export function withEntry<E extends ChatEntry>(map: PathMap<QaThread<E>[]>, key:
return { ...map, [key]: tidy([...map[key] ?? [], { anchor, entries: [entry] }]) }
}
/** 删掉一条;块下面删光了这块就不挂了,文件里删光了这份文件就不记了。 */
/** 删掉一条;块下面删光了这块就不挂了、会话也不接了(下次从头问),文件里删光了这份文件就不记了。 */
export function withoutEntry<E extends ChatEntry>(map: PathMap<QaThread<E>[]>, key: string, anchor: string, entryId: string): PathMap<QaThread<E>[]> {
const threads = tidy((map[key] ?? []).map(thread => thread.anchor === anchor
? { anchor, entries: thread.entries.filter(entry => entry.id !== entryId) }
: thread))
const threads = tidy((map[key] ?? []).flatMap((thread) => {
if (thread.anchor !== anchor)
return [thread]
const entries = thread.entries.filter(entry => entry.id !== entryId)
return entries.length > 0 ? [{ ...thread, entries }] : []
}))
const next = { ...map }
delete next[key]
return threads.length > 0 ? { ...next, [key]: threads } : next
}
/** 在 anchor 那块接着说时接着的会话:这块最近一条的会话 id;没有记录、或那条没记会话 id 时没有,重新开会话。 */
/** 在 anchor 那块接着说时接着的会话:这块最近一次开的会话,答没答完都算;没开过时没有,开新会话。 */
export function latestSession(threads: readonly QaThread<ChatEntry>[] | undefined, anchor: string): string | undefined {
return threads?.find(thread => thread.anchor === anchor)?.entries.at(-1)?.sessionId || undefined
return threads?.find(thread => thread.anchor === anchor)?.session
}
/** 记下 key 这份文件 anchor 那块接着说时要接的会话:开新会话前记,记录不动。 */
export function withSession<E extends ChatEntry>(map: PathMap<QaThread<E>[]>, key: string, anchor: string, session: string): PathMap<QaThread<E>[]> {
return { ...map, [key]: tidy([...map[key] ?? [], { anchor, entries: [], session }]) }
}