diff --git a/vscode/src/cc/spawnClaude.test.ts b/vscode/src/cc/spawnClaude.test.ts index 56b7aa1..7fff74d 100644 --- a/vscode/src/cc/spawnClaude.test.ts +++ b/vscode/src/cc/spawnClaude.test.ts @@ -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((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) }) }) diff --git a/vscode/src/cc/spawnClaude.ts b/vscode/src/cc/spawnClaude.ts index 2295cf3..a689d12 100644 --- a/vscode/src/cc/spawnClaude.ts +++ b/vscode/src/cc/spawnClaude.ts @@ -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 { - 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)) diff --git a/vscode/src/codeReader/commentRewrite.test.ts b/vscode/src/codeReader/commentRewrite.test.ts index 5297254..6fc5f9f 100644 --- a/vscode/src/codeReader/commentRewrite.test.ts +++ b/vscode/src/codeReader/commentRewrite.test.ts @@ -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: '额度用完了' }]) }) }) diff --git a/vscode/src/codeReader/commentRewrite.ts b/vscode/src/codeReader/commentRewrite.ts index 9583def..b09c709 100644 --- a/vscode/src/codeReader/commentRewrite.ts +++ b/vscode/src/codeReader/commentRewrite.ts @@ -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 { @@ -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 }) }] } } diff --git a/vscode/src/codeReader/qa/answerStream.test.ts b/vscode/src/codeReader/qa/answerStream.test.ts index 94db1f6..530acf7 100644 --- a/vscode/src/codeReader/qa/answerStream.test.ts +++ b/vscode/src/codeReader/qa/answerStream.test.ts @@ -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 自己报的失败:取它说的原因', () => { diff --git a/vscode/src/codeReader/qa/answerStream.ts b/vscode/src/codeReader/qa/answerStream.ts index 1a6e74f..335d7eb 100644 --- a/vscode/src/codeReader/qa/answerStream.ts +++ b/vscode/src/codeReader/qa/answerStream.ts @@ -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': diff --git a/vscode/src/codeReader/qa/askProfiles.test.ts b/vscode/src/codeReader/qa/askProfiles.test.ts index e2bccd6..0191633 100644 --- a/vscode/src/codeReader/qa/askProfiles.test.ts +++ b/vscode/src/codeReader/qa/askProfiles.test.ts @@ -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 () => { diff --git a/vscode/src/codeReader/qa/asks.test.ts b/vscode/src/codeReader/qa/asks.test.ts index c1a8f97..c580882 100644 --- a/vscode/src/codeReader/qa/asks.test.ts +++ b/vscode/src/codeReader/qa/asks.test.ts @@ -17,7 +17,7 @@ function fakeRunner(): { runner: (run: AskRun) => Promise, 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, 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']) diff --git a/vscode/src/codeReader/qa/asks.ts b/vscode/src/codeReader/qa/asks.ts index 8263993..ae0d132 100644 --- a/vscode/src/codeReader/qa/asks.ts +++ b/vscode/src/codeReader/qa/asks.ts @@ -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[0] @@ -29,11 +29,39 @@ function fakeClaude(runs: { lines: string[], error?: Error }[]): { deps: AskDeps } } -function request(overrides: Partial = {}): AskRun & { events: unknown[] } { +/** 一次提问;onSession 记下的会话 id 在 sessions 里 */ +function request(overrides: Partial = {}): 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 => 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 & { events: unknown[] } { + /** 一次重写;onSession 记下的会话 id 在 sessions 里 */ + function comment(overrides: Partial = {}): 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): 网络断了') }) }) diff --git a/vscode/src/codeReader/qa/claudeAsk.ts b/vscode/src/codeReader/qa/claudeAsk.ts index 4d4378f..0d1d0c7 100644 --- a/vscode/src/codeReader/qa/claudeAsk.ts +++ b/vscode/src/codeReader/qa/claudeAsk.ts @@ -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 cwd: string /** cwd 以外还要读的目录(别的工作区文件夹、问的工作区外的库文件所在的目录) */ addDirs?: string[] /** 按这个 JSON Schema 交结构化结果(`--json-schema`);只要一段话回答时没有 */ schema?: string signal: AbortSignal - /** 答的过程中:会话 id、回答长出来的字、正在读什么 */ + /** 答的过程中:回答长出来的字、正在读什么 */ onEvent: (event: Exclude) => 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(chat: Pick, once: (prompt: string, session: ClaudeSession) => Promise): Promise { + 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 { - const once = async (prompt: string, resume?: string): Promise => { + 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 cwd: string signal: AbortSignal /** 重写的过程中:正文写出来的字、正在做什么 */ onEvent: (event: Extract) => 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 { const pluginRoot = await deps.pluginRoot(ask.cwd) - const once = async (prompt: string, resume?: string): Promise => { + 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 { /** 写注释的技能所在插件的目录:在 cwd 里问 Claude Code 装了哪些插件。没找到时抛错,说清楚要先装上。 */ export async function commentPluginRoot(cwd: string, runLines: typeof runClaudeLines = runClaudeLines): Promise { 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}。` +} diff --git a/vscode/src/codeReader/qa/codeQa.test.ts b/vscode/src/codeReader/qa/codeQa.test.ts index a6f2fce..057698a 100644 --- a/vscode/src/codeReader/qa/codeQa.test.ts +++ b/vscode/src/codeReader/qa/codeQa.test.ts @@ -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 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>() 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) => Record) { 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[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 | undefined) { + const values = new Map() + const memento = { get: (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 => { + const step = steps[calls.length] + calls.push(options) + atStart.push(recorded(values)) + if (step === 'hang') { + await new Promise((_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> { + return new Promise((resolve) => { + qa.onDidAnswer((event) => { + if (event.kind === 'end' && event.ask.askId === askId) + resolve(event) + }) + }) + } + + function rewritten(qa: CodeQa, askId: string): Promise> { + 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) + }) +}) diff --git a/vscode/src/codeReader/qa/codeQa.ts b/vscode/src/codeReader/qa/codeQa.ts index cbe9ce0..0efdcb1 100644 --- a/vscode/src/codeReader/qa/codeQa.ts +++ b/vscode/src/codeReader/qa/codeQa.ts @@ -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、把带的图片写进 `/.spx/code-qa/`。图片写不进时抛错。 */ -async function chatEntry(rootPath: string, question: string, sessionId: string, profile: ClaudeProfile | undefined, images: readonly ClaudeImage[]): Promise { +async function chatEntry(rootPath: string, question: string, profile: ClaudeProfile | undefined, images: readonly ClaudeImage[]): Promise { 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(threads: readonly QaThread[], 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 { 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 { 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) { diff --git a/vscode/src/codeReader/qa/pathQa.test.ts b/vscode/src/codeReader/qa/pathQa.test.ts index 791e4f1..264f4f7 100644 --- a/vscode/src/codeReader/qa/pathQa.test.ts +++ b/vscode/src/codeReader/qa/pathQa.test.ts @@ -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') diff --git a/vscode/src/codeReader/qa/pathQa.ts b/vscode/src/codeReader/qa/pathQa.ts index 71779da..a176f76 100644 --- a/vscode/src/codeReader/qa/pathQa.ts +++ b/vscode/src/codeReader/qa/pathQa.ts @@ -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 +/** 一处顶部的问答,和这里下一次接着说时接的会话。 */ +export interface PathQa { + /** 按问的先后 */ + entries: QaEntry[] + /** 这里最近一次开的 Claude Code 会话 id,开新会话前就记下,没答完的那一轮也算;没开过时没有 */ + session?: string +} + +/** 记录的键(见 [pathQaKey]、[featureQaKey])→ 那里的问答 */ +export type PathQaMap = Record /** 工作区根的相对路径是空字符串,跟任何一个文件或子目录都不同。 */ 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')) diff --git a/vscode/src/codeReader/qa/qaMap.test.ts b/vscode/src/codeReader/qa/qaMap.test.ts index a22b861..a469d40 100644 --- a/vscode/src/codeReader/qa/qaMap.test.ts +++ b/vscode/src/codeReader/qa/qaMap.test.ts @@ -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 => fsp.access(file).then(() => true, () => false) function rewrite(id: string, createdAt: string, extra: Partial = {}): 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) diff --git a/vscode/src/codeReader/qa/qaMap.ts b/vscode/src/codeReader/qa/qaMap.ts index c3fac76..702d449 100644 --- a/vscode/src/codeReader/qa/qaMap.ts +++ b/vscode/src/codeReader/qa/qaMap.ts @@ -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 { anchor: string entries: E[] + /** 这块最近一次开的 Claude Code 会话 id,开新会话前就记下,没答完的那一轮也算;没开过时没有 */ + session?: string } export type QaMap = PathMap @@ -58,7 +60,6 @@ function parseChat(value: Record): 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(threads: QaThread[]): QaThread[] { - const byAnchor = new Map() - 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>() + 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(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(path: string, label: string, parseEntry: (value: unknown) => E | undefined): PathMapFile[]> { return { path, @@ -135,17 +147,25 @@ export function withEntry(map: PathMap[]>, key: return { ...map, [key]: tidy([...map[key] ?? [], { anchor, entries: [entry] }]) } } -/** 删掉一条;块下面删光了这块就不挂了,文件里删光了这份文件就不记了。 */ +/** 删掉一条;块下面删光了这块就不挂了、会话也不接了(下次从头问),文件里删光了这份文件就不记了。 */ export function withoutEntry(map: PathMap[]>, key: string, anchor: string, entryId: string): PathMap[]> { - 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[] | 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(map: PathMap[]>, key: string, anchor: string, session: string): PathMap[]> { + return { ...map, [key]: tidy([...map[key] ?? [], { anchor, entries: [], session }]) } }