✨ feat(vscode): 新增会话 transcript 推断 profilePath 纯模块
This commit is contained in:
@@ -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 → 返回该 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<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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user