feat(vscode): 新增会话 transcript 推断 profilePath 纯模块

This commit is contained in:
2026-07-25 01:57:05 +08:00
parent 8d00676bba
commit 45b2a7797d
2 changed files with 344 additions and 0 deletions
@@ -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()
})
})
@@ -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<string, unknown>
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 → 返回该 path0 或多个 → 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<string>()
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<string | null> {
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
}
}