diff --git a/vscode/src/sessions/sessionProfileInference.test.ts b/vscode/src/sessions/sessionProfileInference.test.ts new file mode 100644 index 0000000..bb399fe --- /dev/null +++ b/vscode/src/sessions/sessionProfileInference.test.ts @@ -0,0 +1,195 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + collectProfileModelIds, + extractLastAssistantModel, + inferProfilePathForSession, + matchProfilePathForModel, + normalizeModelId, +} from './sessionProfileInference' + +const tempDirs: string[] = [] + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop() + if (dir) + rmSync(dir, { recursive: true, force: true }) + } +}) + +function tempDir(prefix: string): string { + const dir = mkdtempSync(path.join(os.tmpdir(), prefix)) + tempDirs.push(dir) + return dir +} + +describe('normalizeModelId', () => { + it('小写并去掉 -build 后缀', () => { + expect(normalizeModelId('Grok-4.5-build')).toBe('grok-4.5') + }) + + it('去掉方括号后缀如 [1m]', () => { + expect(normalizeModelId('grok-4.5[1m]')).toBe('grok-4.5') + }) + + it('trim + 大小写 + 方括号 + -build 组合', () => { + expect(normalizeModelId(' Grok-4.5-build[1m] ')).toBe('grok-4.5') + }) + + it('不剥离版本号本身', () => { + expect(normalizeModelId('claude-opus-4-20250514')).toBe('claude-opus-4-20250514') + }) + + it('不剥离 -preview', () => { + expect(normalizeModelId('model-preview')).toBe('model-preview') + }) + + it('折叠连续空白', () => { + expect(normalizeModelId('grok 4.5')).toBe('grok 4.5') + }) +}) + +describe('extractLastAssistantModel', () => { + it('多条 assistant 取最后一条 message.model', () => { + const text = [ + JSON.stringify({ type: 'assistant', message: { model: 'old-model' } }), + JSON.stringify({ type: 'user', message: 'hi' }), + JSON.stringify({ type: 'assistant', message: { model: 'grok-4.5-build' } }), + ].join('\n') + expect(extractLastAssistantModel(text)).toBe('grok-4.5-build') + }) + + it('无 assistant model 返回 null', () => { + const text = [ + JSON.stringify({ type: 'user', message: 'hi' }), + JSON.stringify({ type: 'assistant', message: { content: 'yo' } }), + ].join('\n') + expect(extractLastAssistantModel(text)).toBeNull() + }) + + it('message.model 为空字符串忽略', () => { + const text = JSON.stringify({ type: 'assistant', message: { model: ' ' } }) + expect(extractLastAssistantModel(text)).toBeNull() + }) + + it('非法 json 行跳过', () => { + const text = [ + 'not-json', + JSON.stringify({ type: 'assistant', message: { model: 'ok-model' } }), + '{broken', + ].join('\n') + expect(extractLastAssistantModel(text)).toBe('ok-model') + }) +}) + +describe('collectProfileModelIds', () => { + it('从 env 收集已知 model 字段的非空字符串', () => { + const ids = collectProfileModelIds({ + env: { + ANTHROPIC_MODEL: 'grok-4.5[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'opus-x', + ANTHROPIC_DEFAULT_SONNET_MODEL: '', + ANTHROPIC_DEFAULT_HAIKU_MODEL: ' ', + ANTHROPIC_SMALL_FAST_MODEL: 'haiku-fast', + OTHER: 'ignore', + }, + }) + expect(ids).toEqual(['grok-4.5[1m]', 'opus-x', 'haiku-fast']) + }) + + it('无 env 或非法结构返回空数组', () => { + expect(collectProfileModelIds(null)).toEqual([]) + expect(collectProfileModelIds({})).toEqual([]) + expect(collectProfileModelIds({ env: 'bad' })).toEqual([]) + }) +}) + +describe('matchProfilePathForModel', () => { + const grok = '/profiles/grok-4.5.json' + const official = '/profiles/offical.json' + const other = '/profiles/other.json' + + it('唯一 normalize 命中返回 path', () => { + const path_ = matchProfilePathForModel('grok-4.5-build', [ + { path: official, modelIds: [] }, + { path: grok, modelIds: ['grok-4.5[1m]'] }, + ]) + expect(path_).toBe(grok) + }) + + it('零命中返回 null', () => { + expect(matchProfilePathForModel('claude-opus-4', [ + { path: grok, modelIds: ['grok-4.5[1m]'] }, + { path: official, modelIds: [] }, + ])).toBeNull() + }) + + it('多个不同 path 命中返回 null(歧义)', () => { + expect(matchProfilePathForModel('shared-model', [ + { path: grok, modelIds: ['shared-model'] }, + { path: other, modelIds: ['SHARED-MODEL'] }, + ])).toBeNull() + }) + + it('同一 path 多 modelIds 命中仍唯一', () => { + expect(matchProfilePathForModel('m1', [ + { path: grok, modelIds: ['m1', 'm1-alt'] }, + ])).toBe(grok) + }) +}) + +describe('inferProfilePathForSession', () => { + it('jsonl grok model 唯一匹配 grok profile', async () => { + const projectsDir = tempDir('infer-proj-') + const profilesDir = tempDir('infer-prof-') + const sessionId = 'sess-grok' + + writeFileSync( + path.join(projectsDir, `${sessionId}.jsonl`), + [ + JSON.stringify({ type: 'user', message: 'hi' }), + JSON.stringify({ type: 'assistant', message: { model: 'grok-4.5-build' } }), + ].join('\n'), + ) + + const grokPath = path.join(profilesDir, 'grok-4.5.json') + writeFileSync(grokPath, JSON.stringify({ + env: { ANTHROPIC_MODEL: 'grok-4.5[1m]' }, + })) + writeFileSync(path.join(profilesDir, 'offical.json'), JSON.stringify({ + env: {}, + })) + + const inferred = await inferProfilePathForSession(sessionId, projectsDir, profilesDir) + expect(inferred).toBe(grokPath) + }) + + it('会话文件缺失返回 null', async () => { + const projectsDir = tempDir('infer-miss-') + const profilesDir = tempDir('infer-miss-p-') + mkdirSync(profilesDir, { recursive: true }) + expect(await inferProfilePathForSession('no-such', projectsDir, profilesDir)).toBeNull() + }) + + it('无唯一匹配返回 null', async () => { + const projectsDir = tempDir('infer-amb-') + const profilesDir = tempDir('infer-amb-p-') + const sessionId = 'sess-x' + + writeFileSync( + path.join(projectsDir, `${sessionId}.jsonl`), + JSON.stringify({ type: 'assistant', message: { model: 'shared' } }), + ) + writeFileSync(path.join(profilesDir, 'a.json'), JSON.stringify({ + env: { ANTHROPIC_MODEL: 'shared' }, + })) + writeFileSync(path.join(profilesDir, 'b.json'), JSON.stringify({ + env: { ANTHROPIC_MODEL: 'shared' }, + })) + + expect(await inferProfilePathForSession(sessionId, projectsDir, profilesDir)).toBeNull() + }) +}) diff --git a/vscode/src/sessions/sessionProfileInference.ts b/vscode/src/sessions/sessionProfileInference.ts new file mode 100644 index 0000000..416af35 --- /dev/null +++ b/vscode/src/sessions/sessionProfileInference.ts @@ -0,0 +1,149 @@ +import { readdir, readFile } from 'node:fs/promises' +import * as path from 'node:path' +import { getProfilesDir } from '../cc/profiles' + +/** 归一化 model id:小写、去 [1m]、去 -build;保留主版本号 */ +export function normalizeModelId(raw: string): string { + let s = raw.trim().toLowerCase() + s = s.replace(/\[[^\]]*\]/g, '') + s = s.replace(/-build$/, '') + s = s.replace(/\s+/g, ' ').trim() + return s +} + +/** 从 JSONL 全文取「最后一个」assistant.message.model;无则 null */ +export function extractLastAssistantModel(jsonlText: string): string | null { + let last: string | null = null + const lines = jsonlText.split(/\r?\n/) + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) + continue + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } + catch { + continue + } + if (!parsed || typeof parsed !== 'object') + continue + const rec = parsed as { type?: unknown, message?: unknown } + if (rec.type !== 'assistant') + continue + if (!rec.message || typeof rec.message !== 'object') + continue + const model = (rec.message as { model?: unknown }).model + if (typeof model !== 'string') + continue + const value = model.trim() + if (!value) + continue + last = value + } + return last +} + +const PROFILE_MODEL_ENV_KEYS = [ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_SMALL_FAST_MODEL', +] as const + +/** + * 读 profile JSON 的 env 字段,收集可匹配的 model 字符串。 + * 仅字符串且非空才纳入。 + */ +export function collectProfileModelIds(profileJson: unknown): string[] { + if (!profileJson || typeof profileJson !== 'object') + return [] + const env = (profileJson as { env?: unknown }).env + if (!env || typeof env !== 'object') + return [] + const rec = env as Record + const ids: string[] = [] + for (const key of PROFILE_MODEL_ENV_KEYS) { + const value = rec[key] + if (typeof value !== 'string') + continue + const trimmed = value.trim() + if (!trimmed) + continue + ids.push(value) + } + return ids +} + +/** + * 给定 transcript model + profiles 列表,normalize 后精确匹配: + * 唯一 path → 返回该 path;0 或多个 → null + */ +export function matchProfilePathForModel( + transcriptModel: string, + profiles: Array<{ path: string, modelIds: string[] }>, +): string | null { + const target = normalizeModelId(transcriptModel) + if (!target) + return null + + const matched = new Set() + for (const profile of profiles) { + for (const id of profile.modelIds) { + if (normalizeModelId(id) === target) { + matched.add(profile.path) + break + } + } + } + + if (matched.size !== 1) + return null + return matched.values().next().value ?? null +} + +/** + * 读 session JSONL + 扫描 profilesDir 下 *.json, + * 返回唯一匹配的 profile 绝对路径,否则 null。 + * IO 失败返回 null。 + */ +export async function inferProfilePathForSession( + sessionId: string, + projectsDir: string, + profilesDir?: string, +): Promise { + try { + const sessionPath = path.join(projectsDir, `${sessionId}.jsonl`) + const jsonlText = await readFile(sessionPath, 'utf8') + const transcriptModel = extractLastAssistantModel(jsonlText) + if (!transcriptModel) + return null + + const dir = profilesDir ?? getProfilesDir() + const entries = await readdir(dir) + const profiles: Array<{ path: string, modelIds: string[] }> = [] + for (const name of entries) { + if (!name.endsWith('.json')) + continue + const profilePath = path.join(dir, name) + try { + const raw = await readFile(profilePath, 'utf8') + const parsed: unknown = JSON.parse(raw) + profiles.push({ + path: profilePath, + modelIds: collectProfileModelIds(parsed), + }) + } + catch { + // 跳过坏 profile,继续扫描其余 + continue + } + } + + return matchProfilePathForModel(transcriptModel, profiles) + } + catch { + return null + } +}