✨ feat(vscode): 代码阅读加「问 AI」,块下面问 Claude Code,问答存进 .spx 挂在块下

This commit is contained in:
cruldra committed 2026-09-26 21:12:47 +08:00
1 parent badc722640
commit fb53bb727c
34 files changed
+2726 -43

No files matched your search

+116
View File
@@ -0,0 +1,116 @@
import type { runClaudeLines } from '../../cc/spawnClaude'
import type { AskDeps, AskRun } from './claudeAsk'
import { describe, expect, it } from 'vitest'
import { CLAUDE_MISSING_MSG, ClaudeCancelledError, ClaudeError, ClaudeTimeoutError } from '../../cc/spawnClaude'
import { askArgs, askClaude, askFailure } from './claudeAsk'
type RunLinesOptions = Parameters<typeof runClaudeLines>[0]
const line = (value: unknown): string => JSON.stringify(value)
const init = (session: string): string => line({ type: 'system', subtype: 'init', session_id: session })
const delta = (text: string): string => line({ type: 'stream_event', event: { type: 'content_block_delta', delta: { type: 'text_delta', text } }, parent_tool_use_id: null })
const result = (text: string, session: string): string => line({ type: 'result', subtype: 'success', is_error: false, result: text, session_id: session })
/** 假的 claude:记下每次的参数,按次序吐出事先给的行;给了 error 就在吐完后抛它 */
function fakeClaude(runs: { lines: string[], error?: Error }[]): { deps: AskDeps, calls: RunLinesOptions[] } {
const calls: RunLinesOptions[] = []
return {
calls,
deps: {
profilePath: () => '/profiles/offical.json',
runLines: async (options) => {
const run = runs[calls.length]
calls.push(options)
run.lines.forEach(options.onLine)
if (run.error)
throw run.error
},
},
}
}
function request(overrides: Partial<AskRun> = {}): AskRun & { events: unknown[] } {
const events: unknown[] = []
return { prompt: '完整的问题', cwd: '/work', signal: new AbortController().signal, onEvent: event => events.push(event), events, ...overrides }
}
describe('命令行参数', () => {
it('只读:只给读、搜的工具,别的一律拒、不等人批,不加载 MCP;不带跳过权限;输出逐字的 stream-json', () => {
const args = askArgs('问题', { profilePath: '/p/offical.json' })
expect(args).toEqual([
'--settings',
'/p/offical.json',
'--tools',
'Read,Grep,Glob',
'--allowedTools',
'Read,Grep,Glob',
'--permission-mode',
'dontAsk',
'--strict-mcp-config',
'-p',
'问题',
'--output-format',
'stream-json',
'--verbose',
'--include-partial-messages',
])
expect(args.join(' ')).not.toMatch(/dangerously|bypassPermissions|Edit|Write|Bash/)
})
it('追问带 --resume,放在问题前面;没有 profile 就不带 --settings', () => {
const args = askArgs('追问', { resume: 'session-1' })
expect(args).not.toContain('--settings')
expect(args.slice(args.indexOf('--resume'), args.indexOf('--resume') + 4)).toEqual(['--resume', 'session-1', '-p', '追问'])
})
})
describe('问一次', () => {
it('在工作区文件夹里跑、带超时和叫停;过程交给 onEvent,答完返回最终回答和会话 id', async () => {
const { deps, calls } = fakeClaude([{ lines: [init('s1'), delta('答'), delta('案'), result('答案', 's1')] }])
const run = request()
expect(await askClaude(run, deps)).toEqual({ text: '答案', sessionId: 's1' })
expect(run.events).toEqual([{ kind: 'session', sessionId: 's1' }, { 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].args).toContain('/profiles/offical.json')
expect(calls[0].args[calls[0].args.indexOf('-p') + 1]).toBe('完整的问题')
})
it('追问接着上次的会话问短问题', async () => {
const { deps, calls } = fakeClaude([{ lines: [result('接着答', 's1')] }])
expect(await askClaude(request({ followUp: { sessionId: 's1', prompt: '短问题' } }), deps)).toEqual({ text: '接着答', sessionId: 's1' })
expect(calls[0].args.slice(calls[0].args.indexOf('--resume'), calls[0].args.indexOf('--resume') + 4)).toEqual(['--resume', 's1', '-p', '短问题'])
})
it('上次的会话找不到了:按完整问题重新开一次', 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({ followUp: { sessionId: 's0', prompt: '短问题' } }), deps)).toEqual({ text: '重新答', sessionId: 's2' })
expect(calls).toHaveLength(2)
expect(calls[1].args).not.toContain('--resume')
expect(calls[1].args[calls[1].args.indexOf('-p') + 1]).toBe('完整的问题')
})
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)
expect(failing.calls).toHaveLength(1)
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('没给出回答')
})
})
describe('失败了给人看的话', () => {
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): 网络断了')
expect(askFailure(new ClaudeCancelledError(''))).toBe('Claude Code 没答成:Claude 调用已停止')
})
})