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