@zhushanwen/pi-provider-live-sync 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -4
- package/package.json +5 -3
- package/skills/provider-live-sync-ext-config/SKILL.md +53 -15
- package/src/__tests__/catalog-bridge.test.ts +891 -0
- package/src/__tests__/model-sync.test.ts +6 -32
- package/src/__tests__/pi-runtime-testkit.ts +57 -0
- package/src/__tests__/pi-semantics-catalog-injection.test.ts +632 -0
- package/src/__tests__/sdk-contract.test.ts +290 -0
- package/src/__tests__/tick-harness.ts +48 -0
- package/src/catalog-bridge.ts +482 -0
- package/src/index.ts +101 -34
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* catalog-bridge 单元测试(设计 §4.2「单元(extension)」的投喂分支断言)。
|
|
3
|
+
*
|
|
4
|
+
* 被测面 = `src/catalog-bridge.ts` 的投喂分支:env 检测(R1)→ 两份缓存新者胜合并 →
|
|
5
|
+
* 三态判定与并集(D2)→ `registerProvider` + `refreshModels` 钩子(D1/D5)→ 运行中注册
|
|
6
|
+
* 对账(`reconcile`:工厂期非 fresh 的 provider 在缓存变 fresh 后补注册)→ mtime 短路
|
|
7
|
+
* → R2/R3/R4/R5/R6/R7 的 fail-safe 降级。判据本体(解析/合并/三态/并集)的单测在
|
|
8
|
+
* llm-shared 包,本文件只测「IO + 日志 + 注册」这一层的接线与降级。
|
|
9
|
+
*
|
|
10
|
+
* 与 model-sync.test.ts 的分工:那份测既有 models.json/auth.json 同步的 per-file 比较
|
|
11
|
+
* 语义(本文件一行不改它);本文件测新增的目录投喂,直接调 `injectCatalogOverlay`,
|
|
12
|
+
* 不经 tick(文末「tick 接线」组除外——它证的是 index.ts 的接线)。
|
|
13
|
+
*
|
|
14
|
+
* 隔离方式:
|
|
15
|
+
* - env:`TAIJI_AGENT_DATA_DIR` / `PI_CODING_AGENT_DIR` 各指一个 mkdtemp 真实目录
|
|
16
|
+
* (vitest 防线的 global-setup 恒把前者钉到 tmp,故每个用例显式覆盖并还原);
|
|
17
|
+
* - logger:mock `@zhushanwen/pi-extension-logger` 的 `getLogger` 返回 spy 三元组——
|
|
18
|
+
* debug/warn/error 的分级正是 R2/R3/R5/R7 的断言对象,而真实 logger 的 debug 只落
|
|
19
|
+
* 文件日志(无 appendEntry 出口),不 mock 就断言不了级别;
|
|
20
|
+
* - pi:fake `{ registerProvider, on, appendEntry }`,`registerProvider` 是 spy——
|
|
21
|
+
* R4 的「抛错不扩散」靠它按 provider 选择性抛错来驱动。
|
|
22
|
+
*
|
|
23
|
+
* mtime 控制用真实临时文件 + `utimesSync` 钉死时刻戳(不用 fake timers——被测的是
|
|
24
|
+
* 文件系统的 mtime 比较,fake 时钟碰不到它)。
|
|
25
|
+
*/
|
|
26
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
27
|
+
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
|
|
28
|
+
import { tmpdir } from 'node:os'
|
|
29
|
+
import { join } from 'node:path'
|
|
30
|
+
import { getBuiltinModelDataGeneratedAt, getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
|
|
31
|
+
import type { ExtensionAPI, ProviderConfig, ProviderModelConfig } from '@earendil-works/pi-coding-agent'
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
createOverlayCacheWatcher,
|
|
35
|
+
injectCatalogOverlay,
|
|
36
|
+
type CatalogOverlayBridge,
|
|
37
|
+
type OverlayCacheWatcher,
|
|
38
|
+
} from '../catalog-bridge.ts'
|
|
39
|
+
import { startWithRegistryCtx, tickOnce } from './tick-harness.ts'
|
|
40
|
+
|
|
41
|
+
const loggerMocks = vi.hoisted(() => ({
|
|
42
|
+
debug: vi.fn(),
|
|
43
|
+
warn: vi.fn(),
|
|
44
|
+
error: vi.fn(),
|
|
45
|
+
info: vi.fn(),
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
/** mock pi 宿主的 getAgentDir 指向真实 tmp fixture(与 model-sync.test.ts 同手法)。
|
|
49
|
+
* 全部用例都需要它:catalog-bridge 的 pi store 路径经 llm-shared 的 getPiStorePath
|
|
50
|
+
* 委托 pi getAgentDir 推导(不读 env),mock 不指向 tmp 会落到相对路径。 */
|
|
51
|
+
const piMocks = vi.hoisted(() => ({ agentDir: '' }))
|
|
52
|
+
|
|
53
|
+
vi.mock('@zhushanwen/pi-extension-logger', () => ({
|
|
54
|
+
getLogger: () => loggerMocks,
|
|
55
|
+
setPiHandle: () => {},
|
|
56
|
+
createLogger: () => loggerMocks,
|
|
57
|
+
clearRateLimiterState: () => {},
|
|
58
|
+
}))
|
|
59
|
+
|
|
60
|
+
vi.mock('@earendil-works/pi-coding-agent', () => ({
|
|
61
|
+
getAgentDir: () => piMocks.agentDir,
|
|
62
|
+
}))
|
|
63
|
+
|
|
64
|
+
// ── 夹具常量 ──────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/** staleness 基准时刻(pi-ai 官方导出);fresh 条目必须严格晚于它。 */
|
|
67
|
+
const GENERATED_AT = getBuiltinModelDataGeneratedAt() ?? 0
|
|
68
|
+
const FRESH_AT = GENERATED_AT + 60_000
|
|
69
|
+
/** 远端 404/501 落盘的否定信号(lastModified:0 恒判 expired)。 */
|
|
70
|
+
const NEGATIVE_SIGNAL_AT = 0
|
|
71
|
+
|
|
72
|
+
/** 有内置基线的真实 catalog provider(3 个 chat 模型)——并集断言要用真基线。 */
|
|
73
|
+
const REAL_PROVIDER = 'minimax-cn'
|
|
74
|
+
/** 无内置基线的合成 provider id——R3 的「同 id 无可继承定义」分支要靠空基线构造。 */
|
|
75
|
+
const SYNTHETIC_PROVIDER = 'overlay-test-provider'
|
|
76
|
+
|
|
77
|
+
/** mtime 钉死用的三个固定时刻(相差足够大,避免文件系统时间粒度吞掉差异)。 */
|
|
78
|
+
const MTIME_T0 = new Date('2024-01-02T03:04:05.000Z')
|
|
79
|
+
const MTIME_T1 = new Date('2024-06-07T08:09:10.000Z')
|
|
80
|
+
const MTIME_T2 = new Date('2025-01-02T03:04:05.000Z')
|
|
81
|
+
|
|
82
|
+
type RefreshModelsFn = NonNullable<ProviderConfig['refreshModels']>
|
|
83
|
+
type RefreshModelsContext = Parameters<RefreshModelsFn>[0]
|
|
84
|
+
|
|
85
|
+
/** 钩子的 context 入参(被测钩子不读它,只按签名形状造一个可传值)。 */
|
|
86
|
+
function fakeRefreshContext(): RefreshModelsContext {
|
|
87
|
+
return {
|
|
88
|
+
allowNetwork: false,
|
|
89
|
+
signal: new AbortController().signal,
|
|
90
|
+
publish: async () => true,
|
|
91
|
+
} as unknown as RefreshModelsContext
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type FakePi = {
|
|
95
|
+
registerProvider: ReturnType<typeof vi.fn<(provider: string, config: ProviderConfig) => void>>
|
|
96
|
+
on: ReturnType<typeof vi.fn>
|
|
97
|
+
appendEntry: ReturnType<typeof vi.fn>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function makePi(): FakePi {
|
|
101
|
+
return {
|
|
102
|
+
registerProvider: vi.fn<(provider: string, config: ProviderConfig) => void>(),
|
|
103
|
+
on: vi.fn(),
|
|
104
|
+
appendEntry: vi.fn(),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function asApi(pi: FakePi): ExtensionAPI {
|
|
109
|
+
return pi as unknown as ExtensionAPI
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 取某 provider 的注册入参(找不到即抛——注册缺失是缺陷形态,不是可断言的降级)。 */
|
|
113
|
+
function registeredConfig(pi: FakePi, providerId: string): ProviderConfig {
|
|
114
|
+
const call = pi.registerProvider.mock.calls.find((c) => c[0] === providerId)
|
|
115
|
+
if (call === undefined) throw new Error(`provider ${providerId} 未被 registerProvider 注册`)
|
|
116
|
+
return call[1]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function modelIds(config: ProviderConfig): string[] {
|
|
120
|
+
return (config.models ?? []).map((m) => m.id)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 调某 provider 的 refreshModels 钩子(R6/R7 的 undefined 分支原样返回,不做转换)。 */
|
|
124
|
+
async function callHook(pi: FakePi, providerId: string): Promise<ProviderModelConfig[] | undefined> {
|
|
125
|
+
const hook = registeredConfig(pi, providerId).refreshModels
|
|
126
|
+
if (hook === undefined) throw new Error(`provider ${providerId} 的注册未携带 refreshModels 钩子`)
|
|
127
|
+
return hook(fakeRefreshContext())
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── 生命周期(env + 临时目录)──────────────────────────────
|
|
131
|
+
|
|
132
|
+
let dataDir: string
|
|
133
|
+
let agentDir: string
|
|
134
|
+
let savedDataDir: string | undefined
|
|
135
|
+
let savedAgentDirEnv: string | undefined
|
|
136
|
+
|
|
137
|
+
function restoreEnv(key: string, value: string | undefined): void {
|
|
138
|
+
if (value === undefined) delete process.env[key]
|
|
139
|
+
else process.env[key] = value
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
beforeEach(() => {
|
|
143
|
+
dataDir = mkdtempSync(join(tmpdir(), 'catalog-bridge-data-'))
|
|
144
|
+
agentDir = mkdtempSync(join(tmpdir(), 'catalog-bridge-agent-'))
|
|
145
|
+
savedDataDir = process.env.TAIJI_AGENT_DATA_DIR
|
|
146
|
+
savedAgentDirEnv = process.env.PI_CODING_AGENT_DIR
|
|
147
|
+
process.env.TAIJI_AGENT_DATA_DIR = dataDir
|
|
148
|
+
process.env.PI_CODING_AGENT_DIR = agentDir
|
|
149
|
+
piMocks.agentDir = agentDir
|
|
150
|
+
loggerMocks.debug.mockClear()
|
|
151
|
+
loggerMocks.warn.mockClear()
|
|
152
|
+
loggerMocks.error.mockClear()
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
afterEach(() => {
|
|
156
|
+
restoreEnv('TAIJI_AGENT_DATA_DIR', savedDataDir)
|
|
157
|
+
restoreEnv('PI_CODING_AGENT_DIR', savedAgentDirEnv)
|
|
158
|
+
rmSync(dataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
159
|
+
rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// ── 缓存文件写入助手 ──────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
const TAiji_CACHE_PATH = (): string => join(dataDir, 'provider-catalog-overlay.json')
|
|
165
|
+
const PI_STORE_PATH = (): string => join(agentDir, 'models-store.json')
|
|
166
|
+
|
|
167
|
+
/** 写 taiji 自刷缓存(`{ version, entries }` 包装形态)。 */
|
|
168
|
+
function writeTaijiCache(entries: Record<string, unknown>): void {
|
|
169
|
+
writeFileSync(TAiji_CACHE_PATH(), JSON.stringify({ version: 1, entries }))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 写 pi store(顶层 providerId 分桶形态)。 */
|
|
173
|
+
function writePiStore(entries: Record<string, unknown>): void {
|
|
174
|
+
writeFileSync(PI_STORE_PATH(), JSON.stringify(entries))
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 缓存条目(pi.dev 返回形状的宽松子集)。 */
|
|
178
|
+
function entry(models: Array<Record<string, unknown>>, lastModified: number) {
|
|
179
|
+
return { models, checkedAt: FRESH_AT, lastModified }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 自带端点的模型定义(过 R3 过滤的最小完整形状)。 */
|
|
183
|
+
function endpointModel(id: string, name = id) {
|
|
184
|
+
return { id, name, api: 'openai-completions', baseUrl: `https://example.invalid/${id}` }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 写 taiji 缓存条目后注入(单条目注册用例的共同路径:写缓存 → makePi → inject)。 */
|
|
188
|
+
function injectWithEntries(entries: Record<string, unknown>): FakePi {
|
|
189
|
+
writeTaijiCache(entries)
|
|
190
|
+
const pi = makePi()
|
|
191
|
+
injectCatalogOverlay(asApi(pi))
|
|
192
|
+
return pi
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 播种单模型(Model-A)缓存并钉 mtime —— hook / mtime 短路 / watcher 用例的共同开场。 */
|
|
196
|
+
function seedCatalogPinned(at: Date): void {
|
|
197
|
+
writeTaijiCache({ [SYNTHETIC_PROVIDER]: entry([endpointModel('Model-A')], FRESH_AT) })
|
|
198
|
+
utimesSync(TAiji_CACHE_PATH(), at, at)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 播种 **expired** 单模型缓存并钉 mtime(工厂期 R5 不注入 → 运行中变 fresh 的跃迁开场)。 */
|
|
202
|
+
function seedExpiredPinned(at: Date): void {
|
|
203
|
+
writeTaijiCache({ [SYNTHETIC_PROVIDER]: entry([endpointModel('Model-A')], GENERATED_AT) })
|
|
204
|
+
utimesSync(TAiji_CACHE_PATH(), at, at)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 改写缓存为 A+B 两模型并钉新 mtime(模拟上游目录更新 / 钩子返回新并集)。 */
|
|
208
|
+
function rewriteCatalogPinned(at: Date): void {
|
|
209
|
+
writeTaijiCache({
|
|
210
|
+
[SYNTHETIC_PROVIDER]: entry([endpointModel('Model-A'), endpointModel('Model-B')], FRESH_AT),
|
|
211
|
+
})
|
|
212
|
+
utimesSync(TAiji_CACHE_PATH(), at, at)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 注册一个 fresh 注入并断言 Model-A 落表(缓存已写、mtime 钉 T0)。 */
|
|
216
|
+
function injectFresh(): FakePi {
|
|
217
|
+
seedCatalogPinned(MTIME_T0)
|
|
218
|
+
const pi = makePi()
|
|
219
|
+
injectCatalogOverlay(asApi(pi))
|
|
220
|
+
expect(modelIds(registeredConfig(pi, SYNTHETIC_PROVIDER))).toEqual(['Model-A'])
|
|
221
|
+
return pi
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 注入并同时返回 pi 与桥(对账用例要同时持有 registerProvider spy 与 reconcile 入口)。 */
|
|
225
|
+
function injectBridge(): { pi: FakePi; bridge: CatalogOverlayBridge } {
|
|
226
|
+
const pi = makePi()
|
|
227
|
+
const bridge = injectCatalogOverlay(asApi(pi))
|
|
228
|
+
if (bridge === null) throw new Error('injectCatalogOverlay 返回 null:env 未在位(R1),对账用例前置不成立')
|
|
229
|
+
return { pi, bridge }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 播种 + 注入并返回监视条目(watcher 用例的共同开场)。 */
|
|
233
|
+
function injectWatcherPinned(at: Date): OverlayCacheWatcher | undefined {
|
|
234
|
+
seedCatalogPinned(at)
|
|
235
|
+
const bridge = injectCatalogOverlay(asApi(makePi()))
|
|
236
|
+
expect(bridge).not.toBeNull()
|
|
237
|
+
return bridge?.watcher
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─────────────────────────────────────────────────────────
|
|
241
|
+
describe('注入全链路(D1 注册时机 + D2 注入集并集)', () => {
|
|
242
|
+
it('env 在位 + fresh → registerProvider 被调,models = 内置基线 ⊕ 缓存条目(同 id 整对象覆盖)', () => {
|
|
243
|
+
const baseline = getBuiltinModels(REAL_PROVIDER)
|
|
244
|
+
expect(baseline.length).toBeGreaterThan(0)
|
|
245
|
+
// 同 id 覆盖:缓存版本整对象替换基线版本(对齐 pi mergeModels 语义)
|
|
246
|
+
const overridden = { ...baseline[0], name: 'Overlay-Version-Of-Builtin' }
|
|
247
|
+
writeTaijiCache({
|
|
248
|
+
[REAL_PROVIDER]: entry([overridden, endpointModel('Overlay-Only-New')], FRESH_AT),
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
const pi = makePi()
|
|
252
|
+
const bridge = injectCatalogOverlay(asApi(pi))
|
|
253
|
+
|
|
254
|
+
expect(pi.registerProvider).toHaveBeenCalledTimes(1)
|
|
255
|
+
const config = registeredConfig(pi, REAL_PROVIDER)
|
|
256
|
+
// 并集 = 基线全部 id + overlay-only 新 id(只注 diff 会把基线挤出视图,D2 明令禁止)
|
|
257
|
+
expect(modelIds(config)).toEqual([...baseline.map((m) => m.id), 'Overlay-Only-New'])
|
|
258
|
+
// 同 id 那条是缓存版本,不是基线版本
|
|
259
|
+
expect(config.models?.[0]).toMatchObject({ id: baseline[0].id, name: 'Overlay-Version-Of-Builtin' })
|
|
260
|
+
// D5:注册必须携带 refreshModels 钩子(运行中窗口的更新通道)
|
|
261
|
+
expect(config.refreshModels).toBeTypeOf('function')
|
|
262
|
+
// 注入成功记 debug(V2 验收要能在 TAIJI_AGENT_DEBUG 日志里看到)
|
|
263
|
+
expect(loggerMocks.debug).toHaveBeenCalledWith(
|
|
264
|
+
expect.stringContaining(`catalog overlay injected for ${REAL_PROVIDER}`),
|
|
265
|
+
)
|
|
266
|
+
// 监视条目 + 注册对账入口照挂(tick 挂载用),标签 = taiji 自刷缓存文件名
|
|
267
|
+
expect(bridge).not.toBeNull()
|
|
268
|
+
expect(bridge?.watcher.label).toBe('provider-catalog-overlay.json')
|
|
269
|
+
expect(bridge?.reconcile).toBeTypeOf('function')
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('两份缓存都读:只在 taiji 里的 provider 与只在 store 里的 provider 都被注册', () => {
|
|
273
|
+
// 新者胜合并的粒度是**条目(per provider)**,不是模型——同 provider 的两份条目
|
|
274
|
+
// 二选一,不会把两侧的 models 数组并起来。故「两份都读」用两个互斥 provider 证。
|
|
275
|
+
writePiStore({ 'store-only-provider': entry([endpointModel('Store-Model')], FRESH_AT) })
|
|
276
|
+
const pi = injectWithEntries({ 'taiji-only-provider': entry([endpointModel('Taiji-Model')], FRESH_AT) })
|
|
277
|
+
|
|
278
|
+
expect(pi.registerProvider.mock.calls.map((c) => c[0]).sort()).toEqual([
|
|
279
|
+
'store-only-provider',
|
|
280
|
+
'taiji-only-provider',
|
|
281
|
+
])
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('条目级新者胜:store 的那份更新 → 用 store 版本;taiji 的那份更晚 → 用 taiji 版本', () => {
|
|
285
|
+
const older = entry([endpointModel('Shared-Model', 'From-Older-Source')], FRESH_AT)
|
|
286
|
+
const newer = entry([endpointModel('Shared-Model', 'From-Newer-Source')], FRESH_AT + 1_000)
|
|
287
|
+
|
|
288
|
+
writeTaijiCache({ [SYNTHETIC_PROVIDER]: older })
|
|
289
|
+
writePiStore({ [SYNTHETIC_PROVIDER]: newer })
|
|
290
|
+
let pi = makePi()
|
|
291
|
+
injectCatalogOverlay(asApi(pi))
|
|
292
|
+
expect(registeredConfig(pi, SYNTHETIC_PROVIDER).models?.[0]).toMatchObject({ name: 'From-Newer-Source' })
|
|
293
|
+
|
|
294
|
+
// 反向:taiji 的那份更晚(同 id 平局取前者,故 +2_000 才翻得动)
|
|
295
|
+
writeTaijiCache({
|
|
296
|
+
[SYNTHETIC_PROVIDER]: entry([endpointModel('Shared-Model', 'From-Newer-Taiji')], FRESH_AT + 2_000),
|
|
297
|
+
})
|
|
298
|
+
pi = makePi()
|
|
299
|
+
injectCatalogOverlay(asApi(pi))
|
|
300
|
+
expect(registeredConfig(pi, SYNTHETIC_PROVIDER).models?.[0]).toMatchObject({ name: 'From-Newer-Taiji' })
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
// ─────────────────────────────────────────────────────────
|
|
305
|
+
describe('R1 —— 非 taiji 环境(env 无数据目录)零注册', () => {
|
|
306
|
+
it('TAIJI_AGENT_DATA_DIR 缺失 → 返回 null、零 registerProvider、零 warn/error', () => {
|
|
307
|
+
delete process.env.TAIJI_AGENT_DATA_DIR
|
|
308
|
+
// 缓存文件在位也读不到——env 是唯一的路径来源,缺失即无从定位
|
|
309
|
+
writeTaijiCache({ [SYNTHETIC_PROVIDER]: entry([endpointModel('Never-Reached')], FRESH_AT) })
|
|
310
|
+
|
|
311
|
+
const pi = makePi()
|
|
312
|
+
const bridge = injectCatalogOverlay(asApi(pi))
|
|
313
|
+
|
|
314
|
+
expect(bridge).toBeNull()
|
|
315
|
+
expect(pi.registerProvider).not.toHaveBeenCalled()
|
|
316
|
+
expect(loggerMocks.warn).not.toHaveBeenCalled()
|
|
317
|
+
expect(loggerMocks.error).not.toHaveBeenCalled()
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('env 设成空串等同未设(空串 = 缺失,不是「当前目录」)', () => {
|
|
321
|
+
process.env.TAIJI_AGENT_DATA_DIR = ''
|
|
322
|
+
const pi = makePi()
|
|
323
|
+
expect(injectCatalogOverlay(asApi(pi))).toBeNull()
|
|
324
|
+
expect(pi.registerProvider).not.toHaveBeenCalled()
|
|
325
|
+
})
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// ─────────────────────────────────────────────────────────
|
|
329
|
+
describe('R2 / R5 —— 缓存缺失、损坏与三态非 fresh 都不注入', () => {
|
|
330
|
+
it('缓存文件缺失(首装未开过设置页的正常形态)→ 零注册 + debug 记 absent', () => {
|
|
331
|
+
const pi = makePi()
|
|
332
|
+
injectCatalogOverlay(asApi(pi))
|
|
333
|
+
|
|
334
|
+
expect(pi.registerProvider).not.toHaveBeenCalled()
|
|
335
|
+
expect(loggerMocks.debug).toHaveBeenCalledWith(expect.stringContaining('catalog cache absent'))
|
|
336
|
+
expect(loggerMocks.warn).not.toHaveBeenCalled()
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('缓存损坏(JSON 非法)→ 零注册 + warn 记 corrupt(缺失记 debug、损坏记 warn,级别区分常态与异常);桥照样返回供下一拍对账恢复', () => {
|
|
340
|
+
writeFileSync(TAiji_CACHE_PATH(), '{ not json')
|
|
341
|
+
const pi = makePi()
|
|
342
|
+
const bridge = injectCatalogOverlay(asApi(pi))
|
|
343
|
+
|
|
344
|
+
expect(pi.registerProvider).not.toHaveBeenCalled()
|
|
345
|
+
expect(loggerMocks.warn).toHaveBeenCalledWith(expect.stringContaining('catalog cache corrupt'))
|
|
346
|
+
// R2 不注入但不断链:监视条目与对账入口照样返回,文件修复后下一拍 reconcile 重建注入
|
|
347
|
+
expect(bridge).not.toBeNull()
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('R5 expired(lastModified 不晚于基准时刻)→ 不注入 + debug 记 state=expired', () => {
|
|
351
|
+
writeTaijiCache({ [SYNTHETIC_PROVIDER]: entry([endpointModel('Stale-Model')], GENERATED_AT) })
|
|
352
|
+
const pi = makePi()
|
|
353
|
+
injectCatalogOverlay(asApi(pi))
|
|
354
|
+
|
|
355
|
+
expect(pi.registerProvider).not.toHaveBeenCalled()
|
|
356
|
+
expect(loggerMocks.debug).toHaveBeenCalledWith(
|
|
357
|
+
expect.stringContaining(`catalog overlay not injected for ${SYNTHETIC_PROVIDER}: state=expired`),
|
|
358
|
+
)
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('R5 否定信号(lastModified:0,远端 404/501 落盘)→ 不注入', () => {
|
|
362
|
+
writeTaijiCache({ [SYNTHETIC_PROVIDER]: entry([endpointModel('Negated-Model')], NEGATIVE_SIGNAL_AT) })
|
|
363
|
+
const pi = makePi()
|
|
364
|
+
injectCatalogOverlay(asApi(pi))
|
|
365
|
+
|
|
366
|
+
expect(pi.registerProvider).not.toHaveBeenCalled()
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('R5 lastModified 字段缺失 → 判 expired(对齐 pi remoteModels 的缺失语义,不注入)', () => {
|
|
370
|
+
writeTaijiCache({
|
|
371
|
+
[SYNTHETIC_PROVIDER]: { models: [endpointModel('No-Stamp-Model')], checkedAt: FRESH_AT },
|
|
372
|
+
})
|
|
373
|
+
const pi = makePi()
|
|
374
|
+
injectCatalogOverlay(asApi(pi))
|
|
375
|
+
|
|
376
|
+
expect(pi.registerProvider).not.toHaveBeenCalled()
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('R5 never-seen(两份缓存都没有该 provider)→ 不进计算集,无注册动作也无日志', () => {
|
|
380
|
+
const pi = injectWithEntries({ 'other-provider': entry([endpointModel('Other')], FRESH_AT) })
|
|
381
|
+
|
|
382
|
+
// 计算集 = 缓存里出现过的 provider;未出现的连判定都不做(不是异常,不该有日志)
|
|
383
|
+
expect(pi.registerProvider.mock.calls.map((c) => c[0])).toEqual(['other-provider'])
|
|
384
|
+
expect(loggerMocks.debug).not.toHaveBeenCalledWith(expect.stringContaining('not injected'))
|
|
385
|
+
expect(loggerMocks.warn).not.toHaveBeenCalled()
|
|
386
|
+
})
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
// ─────────────────────────────────────────────────────────
|
|
390
|
+
describe('R3 —— fresh 条目缺 api/baseUrl 且同 id 无内置定义可继承 → 跳过该模型 + warn', () => {
|
|
391
|
+
it('缺端点的模型被滤掉并 warn 指明 provider/model id;同条目里完整的模型照常注入', () => {
|
|
392
|
+
// 合成 provider 无内置基线 → 四级兜底全空,正是 R3 保守档要拦的形态
|
|
393
|
+
writeTaijiCache({
|
|
394
|
+
[SYNTHETIC_PROVIDER]: entry(
|
|
395
|
+
[{ id: 'No-Endpoint-Model', name: 'No-Endpoint-Model' }, endpointModel('Complete-Model')],
|
|
396
|
+
FRESH_AT,
|
|
397
|
+
),
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
const pi = makePi()
|
|
401
|
+
injectCatalogOverlay(asApi(pi))
|
|
402
|
+
|
|
403
|
+
expect(modelIds(registeredConfig(pi, SYNTHETIC_PROVIDER))).toEqual(['Complete-Model'])
|
|
404
|
+
expect(loggerMocks.warn).toHaveBeenCalledWith(
|
|
405
|
+
expect.stringContaining(`${SYNTHETIC_PROVIDER}/No-Endpoint-Model`),
|
|
406
|
+
)
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('同 id 有内置定义可继承 → 不跳过(R3 只认同 id 继承这一条路径)', () => {
|
|
410
|
+
const baseline = getBuiltinModels(REAL_PROVIDER)
|
|
411
|
+
// 缺 api/baseUrl,但同 id 在内置基线里 → 判据允许继承,注入集保留它
|
|
412
|
+
writeTaijiCache({
|
|
413
|
+
[REAL_PROVIDER]: entry([{ id: baseline[0].id, name: 'Inherits-Endpoint' }], FRESH_AT),
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
const pi = makePi()
|
|
417
|
+
injectCatalogOverlay(asApi(pi))
|
|
418
|
+
|
|
419
|
+
expect(modelIds(registeredConfig(pi, REAL_PROVIDER))).toContain(baseline[0].id)
|
|
420
|
+
expect(loggerMocks.warn).not.toHaveBeenCalled()
|
|
421
|
+
})
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
// ─────────────────────────────────────────────────────────
|
|
425
|
+
describe('R4 —— registerProvider 抛错不扩散', () => {
|
|
426
|
+
it('第一个 provider 抛错 → error 日志含 pi 原文与恢复动作,后续 provider 仍注入', () => {
|
|
427
|
+
writeTaijiCache({
|
|
428
|
+
'broken-provider': entry([endpointModel('Broken-Model')], FRESH_AT),
|
|
429
|
+
[SYNTHETIC_PROVIDER]: entry([endpointModel('Healthy-Model')], FRESH_AT),
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
const pi = makePi()
|
|
433
|
+
pi.registerProvider.mockImplementation((provider: string) => {
|
|
434
|
+
if (provider === 'broken-provider') {
|
|
435
|
+
throw new Error('Provider broken-provider: "baseUrl" is required when defining custom models.')
|
|
436
|
+
}
|
|
437
|
+
})
|
|
438
|
+
injectCatalogOverlay(asApi(pi))
|
|
439
|
+
|
|
440
|
+
// 两个都尝试过(抛错不是「跳过后续」的理由)
|
|
441
|
+
expect(pi.registerProvider).toHaveBeenCalledTimes(2)
|
|
442
|
+
// 健康的那个落进了注册表
|
|
443
|
+
expect(modelIds(registeredConfig(pi, SYNTHETIC_PROVIDER))).toEqual(['Healthy-Model'])
|
|
444
|
+
// error 日志含 pi 原文 + 恢复动作
|
|
445
|
+
expect(loggerMocks.error).toHaveBeenCalledWith(
|
|
446
|
+
expect.stringContaining('registerProvider failed for broken-provider'),
|
|
447
|
+
)
|
|
448
|
+
expect(loggerMocks.error).toHaveBeenCalledWith(expect.stringContaining('"baseUrl" is required'))
|
|
449
|
+
expect(loggerMocks.error).toHaveBeenCalledWith(expect.stringContaining('restart the session to retry'))
|
|
450
|
+
})
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
// ─────────────────────────────────────────────────────────
|
|
454
|
+
describe('refreshModels 钩子三分支(D5 运行中窗口)', () => {
|
|
455
|
+
it('fresh → 钩子返回新并集(新模型无需重开会话即可见)', async () => {
|
|
456
|
+
const pi = injectFresh()
|
|
457
|
+
rewriteCatalogPinned(MTIME_T1)
|
|
458
|
+
|
|
459
|
+
const refreshed = await callHook(pi, SYNTHETIC_PROVIDER)
|
|
460
|
+
expect(refreshed?.map((m) => m.id)).toEqual(['Model-A', 'Model-B'])
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it('R6 expired(fresh → 否定信号)→ 钩子返回纯内置基线,等价撤销注入', async () => {
|
|
464
|
+
const pi = injectFresh()
|
|
465
|
+
writeTaijiCache({ [SYNTHETIC_PROVIDER]: entry([endpointModel('Model-A')], NEGATIVE_SIGNAL_AT) })
|
|
466
|
+
utimesSync(TAiji_CACHE_PATH(), MTIME_T1, MTIME_T1)
|
|
467
|
+
|
|
468
|
+
const refreshed = await callHook(pi, SYNTHETIC_PROVIDER)
|
|
469
|
+
// 合成 provider 的内置基线是空数组 → 撤销后可见集合为空
|
|
470
|
+
expect(refreshed).toEqual([])
|
|
471
|
+
expect(loggerMocks.debug).toHaveBeenCalledWith(
|
|
472
|
+
expect.stringContaining(`catalog overlay revoked for ${SYNTHETIC_PROVIDER}: state=expired`),
|
|
473
|
+
)
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
it('R6 撤销后仍有内置基线可保(真实 provider:返回的是基线而非空集)', async () => {
|
|
477
|
+
const baseline = getBuiltinModels(REAL_PROVIDER)
|
|
478
|
+
writeTaijiCache({ [REAL_PROVIDER]: entry([endpointModel('Overlay-Only')], FRESH_AT) })
|
|
479
|
+
utimesSync(TAiji_CACHE_PATH(), MTIME_T0, MTIME_T0)
|
|
480
|
+
const pi = makePi()
|
|
481
|
+
injectCatalogOverlay(asApi(pi))
|
|
482
|
+
expect(modelIds(registeredConfig(pi, REAL_PROVIDER))).toContain('Overlay-Only')
|
|
483
|
+
|
|
484
|
+
writeTaijiCache({ [REAL_PROVIDER]: entry([endpointModel('Overlay-Only')], NEGATIVE_SIGNAL_AT) })
|
|
485
|
+
utimesSync(TAiji_CACHE_PATH(), MTIME_T1, MTIME_T1)
|
|
486
|
+
|
|
487
|
+
const refreshed = await callHook(pi, REAL_PROVIDER)
|
|
488
|
+
expect(refreshed?.map((m) => m.id)).toEqual(baseline.map((m) => m.id))
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('R7 钩子运行期缓存损坏 → warn + 返回 undefined(pi 语义 = 保留当前注入)', async () => {
|
|
492
|
+
const pi = injectFresh()
|
|
493
|
+
writeFileSync(TAiji_CACHE_PATH(), '{ truncated')
|
|
494
|
+
utimesSync(TAiji_CACHE_PATH(), MTIME_T1, MTIME_T1)
|
|
495
|
+
|
|
496
|
+
expect(await callHook(pi, SYNTHETIC_PROVIDER)).toBeUndefined()
|
|
497
|
+
expect(loggerMocks.warn).toHaveBeenCalledWith(
|
|
498
|
+
expect.stringContaining('catalog cache unreadable during refresh'),
|
|
499
|
+
)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it('R7 钩子运行期读失败(非 ENOENT:缓存路径被目录占位 = EISDIR)→ 同样返回 undefined', async () => {
|
|
503
|
+
const pi = injectFresh()
|
|
504
|
+
// 目录位让 readFileSync 抛 EISDIR(非 ENOENT)——与 model-sync.test.ts 的同款手法
|
|
505
|
+
rmSync(TAiji_CACHE_PATH())
|
|
506
|
+
mkdirSync(TAiji_CACHE_PATH())
|
|
507
|
+
|
|
508
|
+
expect(await callHook(pi, SYNTHETIC_PROVIDER)).toBeUndefined()
|
|
509
|
+
expect(loggerMocks.warn).toHaveBeenCalledWith(expect.stringContaining('unreadable'))
|
|
510
|
+
})
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
// ─────────────────────────────────────────────────────────
|
|
514
|
+
/**
|
|
515
|
+
* 注册对账(D5 运行中窗口的另一半):`refreshModels` 钩子只挂在**已注册**的 provider 上,
|
|
516
|
+
* pi-ai 的刷新只碰带钩子的 provider——工厂期 expired / never-seen / 损坏的 provider 单靠
|
|
517
|
+
* 刷新永远重建不了注入。`reconcile()` 对本拍 fresh 且尚未注册的 provider 补注册(挂同一
|
|
518
|
+
* 钩子),下面逐条锁三种跃迁 + 幂等 + read-failed + R4 重试。
|
|
519
|
+
*/
|
|
520
|
+
describe('注册对账 reconcile(工厂期非 fresh → 运行中变 fresh 的跃迁)', () => {
|
|
521
|
+
it('工厂期 expired → 缓存变 fresh → reconcile 返回 true、registerProvider 携带钩子被调,钩子立即可用', async () => {
|
|
522
|
+
seedExpiredPinned(MTIME_T0)
|
|
523
|
+
const { pi, bridge } = injectBridge()
|
|
524
|
+
expect(pi.registerProvider).not.toHaveBeenCalled()
|
|
525
|
+
expect(loggerMocks.debug).toHaveBeenCalledWith(
|
|
526
|
+
expect.stringContaining(`catalog overlay not injected for ${SYNTHETIC_PROVIDER}: state=expired`),
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
rewriteCatalogPinned(MTIME_T1)
|
|
530
|
+
expect(bridge.reconcile()).toBe(true)
|
|
531
|
+
|
|
532
|
+
const config = registeredConfig(pi, SYNTHETIC_PROVIDER)
|
|
533
|
+
expect(modelIds(config)).toEqual(['Model-A', 'Model-B'])
|
|
534
|
+
expect(config.refreshModels).toBeTypeOf('function')
|
|
535
|
+
expect(loggerMocks.debug).toHaveBeenCalledWith(
|
|
536
|
+
expect.stringContaining(`catalog overlay injected for ${SYNTHETIC_PROVIDER}`),
|
|
537
|
+
)
|
|
538
|
+
// 对账注册携带的是同一只钩子:mtime 短路命中(同拍已算过)直接返回新并集
|
|
539
|
+
expect((await callHook(pi, SYNTHETIC_PROVIDER))?.map((m) => m.id)).toEqual(['Model-A', 'Model-B'])
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('工厂期 never-seen(缓存缺失)→ 缓存出现且 fresh → reconcile 注册', () => {
|
|
543
|
+
const { pi, bridge } = injectBridge()
|
|
544
|
+
expect(pi.registerProvider).not.toHaveBeenCalled()
|
|
545
|
+
|
|
546
|
+
seedCatalogPinned(MTIME_T1)
|
|
547
|
+
expect(bridge.reconcile()).toBe(true)
|
|
548
|
+
expect(modelIds(registeredConfig(pi, SYNTHETIC_PROVIDER))).toEqual(['Model-A'])
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it('工厂期损坏(R2)→ 文件修复为 fresh → reconcile 重建注入(恢复路径不依赖重开会话)', () => {
|
|
552
|
+
writeFileSync(TAiji_CACHE_PATH(), '{ not json')
|
|
553
|
+
utimesSync(TAiji_CACHE_PATH(), MTIME_T0, MTIME_T0)
|
|
554
|
+
const { pi, bridge } = injectBridge()
|
|
555
|
+
expect(pi.registerProvider).not.toHaveBeenCalled()
|
|
556
|
+
expect(loggerMocks.warn).toHaveBeenCalledWith(expect.stringContaining('catalog cache corrupt'))
|
|
557
|
+
|
|
558
|
+
seedCatalogPinned(MTIME_T1)
|
|
559
|
+
expect(bridge.reconcile()).toBe(true)
|
|
560
|
+
expect(modelIds(registeredConfig(pi, SYNTHETIC_PROVIDER))).toEqual(['Model-A'])
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('幂等:已注册的 provider 不重复注册(缓存无变化与有变化都返回 false,更新由钩子承担)', () => {
|
|
564
|
+
seedCatalogPinned(MTIME_T0)
|
|
565
|
+
const { pi, bridge } = injectBridge()
|
|
566
|
+
expect(pi.registerProvider).toHaveBeenCalledTimes(1)
|
|
567
|
+
|
|
568
|
+
expect(bridge.reconcile()).toBe(false)
|
|
569
|
+
rewriteCatalogPinned(MTIME_T1)
|
|
570
|
+
expect(bridge.reconcile()).toBe(false)
|
|
571
|
+
expect(pi.registerProvider).toHaveBeenCalledTimes(1)
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
it('对账拍缓存损坏 → 返回 false 不注册(R2 同款:未注册的 provider 没有注入可建);修复后下一次对账注册', () => {
|
|
575
|
+
seedExpiredPinned(MTIME_T0)
|
|
576
|
+
const { pi, bridge } = injectBridge()
|
|
577
|
+
loggerMocks.warn.mockClear()
|
|
578
|
+
|
|
579
|
+
writeFileSync(TAiji_CACHE_PATH(), '{ truncated')
|
|
580
|
+
utimesSync(TAiji_CACHE_PATH(), MTIME_T1, MTIME_T1)
|
|
581
|
+
expect(bridge.reconcile()).toBe(false)
|
|
582
|
+
expect(pi.registerProvider).not.toHaveBeenCalled()
|
|
583
|
+
expect(loggerMocks.warn).toHaveBeenCalledWith(expect.stringContaining('catalog cache corrupt'))
|
|
584
|
+
|
|
585
|
+
seedCatalogPinned(MTIME_T2)
|
|
586
|
+
expect(bridge.reconcile()).toBe(true)
|
|
587
|
+
expect(modelIds(registeredConfig(pi, SYNTHETIC_PROVIDER))).toEqual(['Model-A'])
|
|
588
|
+
})
|
|
589
|
+
|
|
590
|
+
it('R4 在对账期:registerProvider 抛错 → error 日志(含自动重试提示)+ 返回 false;缓存再次重写后重试成功', () => {
|
|
591
|
+
seedExpiredPinned(MTIME_T0)
|
|
592
|
+
const { pi, bridge } = injectBridge()
|
|
593
|
+
pi.registerProvider.mockImplementationOnce(() => {
|
|
594
|
+
throw new Error(`Provider ${SYNTHETIC_PROVIDER}: "baseUrl" is required when defining custom models.`)
|
|
595
|
+
})
|
|
596
|
+
|
|
597
|
+
rewriteCatalogPinned(MTIME_T1)
|
|
598
|
+
expect(bridge.reconcile()).toBe(false)
|
|
599
|
+
expect(pi.registerProvider).toHaveBeenCalledTimes(1)
|
|
600
|
+
expect(loggerMocks.error).toHaveBeenCalledWith(
|
|
601
|
+
expect.stringContaining(`registerProvider failed for ${SYNTHETIC_PROVIDER}`),
|
|
602
|
+
)
|
|
603
|
+
expect(loggerMocks.error).toHaveBeenCalledWith(expect.stringContaining('the next catalog refresh retries'))
|
|
604
|
+
|
|
605
|
+
// 失败的 provider 不入已注册集:下一次缓存重写后的对账重试(这次 spy 不再抛)
|
|
606
|
+
rewriteCatalogPinned(MTIME_T2)
|
|
607
|
+
expect(bridge.reconcile()).toBe(true)
|
|
608
|
+
expect(pi.registerProvider).toHaveBeenCalledTimes(2)
|
|
609
|
+
})
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
// ─────────────────────────────────────────────────────────
|
|
613
|
+
describe('mtime 短路(D5:文件未变直接返回上次结果,不重算)', () => {
|
|
614
|
+
it('内容变了但 mtime 不变 → 钩子返回旧结果(证明没重读);mtime 一变 → 立刻重算', async () => {
|
|
615
|
+
const pi = injectFresh()
|
|
616
|
+
|
|
617
|
+
// 内容换成 Model-B,但把 mtime 钉回原值——短路成立则钩子看不到这次改写
|
|
618
|
+
writeTaijiCache({ [SYNTHETIC_PROVIDER]: entry([endpointModel('Model-B')], FRESH_AT) })
|
|
619
|
+
utimesSync(TAiji_CACHE_PATH(), MTIME_T0, MTIME_T0)
|
|
620
|
+
expect((await callHook(pi, SYNTHETIC_PROVIDER))?.map((m) => m.id)).toEqual(['Model-A'])
|
|
621
|
+
|
|
622
|
+
// mtime 真的变了 → 重算,新内容生效
|
|
623
|
+
utimesSync(TAiji_CACHE_PATH(), MTIME_T1, MTIME_T1)
|
|
624
|
+
expect((await callHook(pi, SYNTHETIC_PROVIDER))?.map((m) => m.id)).toEqual(['Model-B'])
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
it('两份缓存分别控 mtime:只改 store 的那一份也能突破短路', async () => {
|
|
628
|
+
// store 的条目更晚 → 新者胜取它(同 provider 的两份条目二选一,不是模型级并集)
|
|
629
|
+
writeTaijiCache({ [SYNTHETIC_PROVIDER]: entry([endpointModel('Model-A')], FRESH_AT) })
|
|
630
|
+
writePiStore({ [SYNTHETIC_PROVIDER]: entry([endpointModel('Model-A')], FRESH_AT + 1_000) })
|
|
631
|
+
utimesSync(TAiji_CACHE_PATH(), MTIME_T0, MTIME_T0)
|
|
632
|
+
utimesSync(PI_STORE_PATH(), MTIME_T0, MTIME_T0)
|
|
633
|
+
const pi = makePi()
|
|
634
|
+
injectCatalogOverlay(asApi(pi))
|
|
635
|
+
expect(modelIds(registeredConfig(pi, SYNTHETIC_PROVIDER))).toEqual(['Model-A'])
|
|
636
|
+
|
|
637
|
+
// 只改 store 内容 + mtime;taiji 那份原样不动——短路必须被 store 的 mtime 突破
|
|
638
|
+
writePiStore({
|
|
639
|
+
[SYNTHETIC_PROVIDER]: entry([endpointModel('Model-A'), endpointModel('Model-From-Store')], FRESH_AT + 2_000),
|
|
640
|
+
})
|
|
641
|
+
utimesSync(PI_STORE_PATH(), MTIME_T1, MTIME_T1)
|
|
642
|
+
|
|
643
|
+
expect((await callHook(pi, SYNTHETIC_PROVIDER))?.map((m) => m.id)).toEqual([
|
|
644
|
+
'Model-A',
|
|
645
|
+
'Model-From-Store',
|
|
646
|
+
])
|
|
647
|
+
})
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
// ─────────────────────────────────────────────────────────
|
|
651
|
+
describe('第三个监视条目(tick 挂载用的 mtime 观测器)', () => {
|
|
652
|
+
it('首拍建基线报「变化」(由 tick 的 firstBaseline 抑制刷新);同 mtime 再观测 → false', () => {
|
|
653
|
+
const path = TAiji_CACHE_PATH()
|
|
654
|
+
writeFileSync(path, '{}')
|
|
655
|
+
utimesSync(path, MTIME_T0, MTIME_T0)
|
|
656
|
+
const watcher = createOverlayCacheWatcher(path)
|
|
657
|
+
|
|
658
|
+
expect(watcher.observe()).toBe(true)
|
|
659
|
+
expect(watcher.observe()).toBe(false)
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
it('工厂期播种基线 + 窗口内 mtime 变更 → observe() 返回 true(运行中窗口 D5 的检出)', () => {
|
|
663
|
+
// 经 injectCatalogOverlay 取观测器——工厂期在 runPass 之前捕获的 mtime 即初始基线
|
|
664
|
+
// (生产接线;直接调 createOverlayCacheWatcher(path, initial) 测不到这层传递)。
|
|
665
|
+
const watcher = injectWatcherPinned(MTIME_T0)
|
|
666
|
+
|
|
667
|
+
// 工厂期注入与 tick 1 之间(~2s 窗)缓存被更新(taiji runtime 的 tmp+rename 原子替换写)
|
|
668
|
+
rewriteCatalogPinned(MTIME_T1)
|
|
669
|
+
expect(watcher?.observe()).toBe(true)
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it('工厂期播种基线 + 无变更 → observe() 返回 false(首拍不产生幻觉变化)', () => {
|
|
673
|
+
const watcher = injectWatcherPinned(MTIME_T0)
|
|
674
|
+
expect(watcher?.observe()).toBe(false)
|
|
675
|
+
})
|
|
676
|
+
|
|
677
|
+
it('mtime 变化 → true(taiji runtime 原子替换写后的检出)', () => {
|
|
678
|
+
const path = TAiji_CACHE_PATH()
|
|
679
|
+
writeFileSync(path, '{}')
|
|
680
|
+
utimesSync(path, MTIME_T0, MTIME_T0)
|
|
681
|
+
const watcher = createOverlayCacheWatcher(path)
|
|
682
|
+
watcher.observe()
|
|
683
|
+
|
|
684
|
+
writeFileSync(path, '{"version":1,"entries":{}}')
|
|
685
|
+
utimesSync(path, MTIME_T1, MTIME_T1)
|
|
686
|
+
expect(watcher.observe()).toBe(true)
|
|
687
|
+
expect(watcher.observe()).toBe(false)
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it('不存在 → 存在 → true(首次物化);持续不存在 → false(非变化信号)', () => {
|
|
691
|
+
const path = TAiji_CACHE_PATH()
|
|
692
|
+
const watcher = createOverlayCacheWatcher(path)
|
|
693
|
+
|
|
694
|
+
expect(watcher.observe()).toBe(true) // 首拍 absent 建基线
|
|
695
|
+
expect(watcher.observe()).toBe(false) // 持续 absent = 非变化信号
|
|
696
|
+
writeFileSync(path, '{}')
|
|
697
|
+
utimesSync(path, MTIME_T0, MTIME_T0)
|
|
698
|
+
expect(watcher.observe()).toBe(true) // absent → present
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
it('存在 → 缺失 → true(撤销注入也要有触发点)', () => {
|
|
702
|
+
const path = TAiji_CACHE_PATH()
|
|
703
|
+
writeFileSync(path, '{}')
|
|
704
|
+
utimesSync(path, MTIME_T0, MTIME_T0)
|
|
705
|
+
const watcher = createOverlayCacheWatcher(path)
|
|
706
|
+
watcher.observe()
|
|
707
|
+
|
|
708
|
+
rmSync(path)
|
|
709
|
+
expect(watcher.observe()).toBe(true)
|
|
710
|
+
expect(watcher.observe()).toBe(false)
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
it('unreadable(父路径是文件 = ENOTDIR)→ 本拍不比较、不推进基线', () => {
|
|
714
|
+
// statSync 对目录是成功的(只有 readFileSync 才 EISDIR),故 unreadable 分支要用
|
|
715
|
+
// 「父路径不是目录」触发 ENOTDIR——确定性、不依赖权限位(root 无视 chmod)。
|
|
716
|
+
const blocker = join(dataDir, 'not-a-dir')
|
|
717
|
+
writeFileSync(blocker, 'x')
|
|
718
|
+
const path = join(blocker, 'cache.json')
|
|
719
|
+
const watcher = createOverlayCacheWatcher(path)
|
|
720
|
+
|
|
721
|
+
expect(watcher.observe()).toBe(false)
|
|
722
|
+
// 基线未被推进:父路径变成真目录、文件可读后首拍仍报「变化」
|
|
723
|
+
rmSync(blocker)
|
|
724
|
+
mkdirSync(blocker)
|
|
725
|
+
writeFileSync(path, '{}')
|
|
726
|
+
utimesSync(path, MTIME_T0, MTIME_T0)
|
|
727
|
+
expect(watcher.observe()).toBe(true)
|
|
728
|
+
})
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
// ─────────────────────────────────────────────────────────
|
|
732
|
+
/**
|
|
733
|
+
* tick 接线层:第三个监视条目挂进既有 tick 后,「缓存 mtime 变化 → 一次零网络本地
|
|
734
|
+
* 刷新」这条链真的通(设计 D5 运行中窗口 / 验收场景 V6 的单测形态)。
|
|
735
|
+
*
|
|
736
|
+
* 上面那些用例分别证明了钩子与观测器各自正确,但**两者的接线**(index.ts 的 tick 把
|
|
737
|
+
* 观测结果并进刷新决策)只有这一层能覆盖——真机 V6 依赖的正是它。
|
|
738
|
+
*
|
|
739
|
+
* 驱动件与 model-sync.test.ts 的 tick 层共用 `./tick-harness.ts`(fake timers 只替换
|
|
740
|
+
* setTimeout/clearTimeout,tick 链上的 readFile 与 setImmediate 保持真实;拍屏障
|
|
741
|
+
* 说明见该文件头注释)。models.json/auth.json 全程缺席(catalog-only 安装常态),
|
|
742
|
+
* 于是唯一的刷新信号来自目录缓存——隔离得干净。
|
|
743
|
+
*/
|
|
744
|
+
describe('tick 接线 —— 目录缓存 mtime 变化触发一次零网络本地刷新', () => {
|
|
745
|
+
beforeEach(() => {
|
|
746
|
+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
afterEach(() => {
|
|
750
|
+
vi.useRealTimers()
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
/** 「恰一次零网络本地刷新」断言(tick 接线用例共用)。 */
|
|
754
|
+
function expectRefreshedOnce(refresh: ReturnType<typeof vi.fn>): void {
|
|
755
|
+
expect(refresh).toHaveBeenCalledTimes(1)
|
|
756
|
+
expect(refresh).toHaveBeenCalledWith({ allowNetwork: false })
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
type RegisterProviderSpy = ReturnType<typeof vi.fn<(provider: string, config: ProviderConfig) => void>>
|
|
760
|
+
|
|
761
|
+
/** 经 harness 启动扩展(pi 宿主面 = appendEntry + registerProvider spy)。 */
|
|
762
|
+
function startWithFakeCtx(): { refresh: ReturnType<typeof vi.fn>; registerProvider: RegisterProviderSpy } {
|
|
763
|
+
const registerProvider = vi.fn<(provider: string, config: ProviderConfig) => void>()
|
|
764
|
+
const { refresh } = startWithRegistryCtx({ appendEntry: vi.fn(), registerProvider })
|
|
765
|
+
return { refresh, registerProvider }
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
it('首拍只建基线不刷新;缓存 mtime 变化 → refresh({ allowNetwork: false }) 恰一次', async () => {
|
|
769
|
+
seedCatalogPinned(MTIME_T0)
|
|
770
|
+
const { refresh, registerProvider } = startWithFakeCtx()
|
|
771
|
+
// 工厂期已注入(D1)——tick 只是触发方,不是注册方
|
|
772
|
+
expect(registerProvider).toHaveBeenCalledTimes(1)
|
|
773
|
+
|
|
774
|
+
await tickOnce() // 首拍:models.json/auth.json 双 absent + 缓存首观测 → 只建基线
|
|
775
|
+
expect(refresh).not.toHaveBeenCalled()
|
|
776
|
+
|
|
777
|
+
// 上游目录更新(taiji runtime 的 tmp+rename 原子替换写)→ mtime 变
|
|
778
|
+
rewriteCatalogPinned(MTIME_T1)
|
|
779
|
+
await tickOnce()
|
|
780
|
+
expectRefreshedOnce(refresh)
|
|
781
|
+
})
|
|
782
|
+
|
|
783
|
+
it('运行中窗口不断裂:工厂期注入后、首个 tick 触发前改缓存 → 首个 tick 必须 refresh(firstBaseline 不放行 overlay 变化)', async () => {
|
|
784
|
+
seedCatalogPinned(MTIME_T0)
|
|
785
|
+
const { refresh, registerProvider } = startWithFakeCtx()
|
|
786
|
+
// 工厂期已注入(D1)——tick 只是触发方,不是注册方
|
|
787
|
+
expect(registerProvider).toHaveBeenCalledTimes(1)
|
|
788
|
+
|
|
789
|
+
// 「工厂期注入 → tick 1」约 2s 窗内的缓存变更(真机实证:spawn 后 ~200ms 变更时
|
|
790
|
+
// 30s 内新模型不出现)——首拍抑制的前提(pi spawn 时已按磁盘建快照)只对既有
|
|
791
|
+
// models.json/auth.json 成立,overlay 条目的变化必须放行,否则运行中窗口断裂。
|
|
792
|
+
rewriteCatalogPinned(MTIME_T1)
|
|
793
|
+
|
|
794
|
+
await tickOnce() // 首个 tick:既有两条目双 absent 无变化,唯一信号 = overlay 变化
|
|
795
|
+
expectRefreshedOnce(refresh)
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
it('对照不变量:缓存内容变了但 mtime 不变 → 零刷新(mtime 是这一条目的比较基准)', async () => {
|
|
799
|
+
seedCatalogPinned(MTIME_T0)
|
|
800
|
+
const { refresh } = startWithFakeCtx()
|
|
801
|
+
await tickOnce()
|
|
802
|
+
expect(refresh).not.toHaveBeenCalled()
|
|
803
|
+
|
|
804
|
+
// 改写内容但把 mtime 钉回原值——该条目判「未变」
|
|
805
|
+
rewriteCatalogPinned(MTIME_T0)
|
|
806
|
+
await tickOnce()
|
|
807
|
+
expect(refresh).not.toHaveBeenCalled()
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
/** 对账注册的接线断言:tick 内 registerProvider 携带钩子落表、且先于本拍 refresh。 */
|
|
811
|
+
function expectReconciledThenRefreshed(
|
|
812
|
+
registerProvider: RegisterProviderSpy,
|
|
813
|
+
refresh: ReturnType<typeof vi.fn>,
|
|
814
|
+
expectedIds: string[],
|
|
815
|
+
): void {
|
|
816
|
+
expect(registerProvider).toHaveBeenCalledTimes(1)
|
|
817
|
+
const [providerId, config] = registerProvider.mock.calls[0]
|
|
818
|
+
expect(providerId).toBe(SYNTHETIC_PROVIDER)
|
|
819
|
+
expect(modelIds(config)).toEqual(expectedIds)
|
|
820
|
+
expect(config.refreshModels).toBeTypeOf('function')
|
|
821
|
+
expectRefreshedOnce(refresh)
|
|
822
|
+
// 顺序契约:先补注册(挂上钩子)再刷新——反过来刷新碰不到这个 provider
|
|
823
|
+
expect(registerProvider.mock.invocationCallOrder[0]).toBeLessThan(refresh.mock.invocationCallOrder[0])
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
it('跃迁①:工厂期 expired → 缓存变 fresh → tick 内 registerProvider 携带钩子被调(先于 refresh)', async () => {
|
|
827
|
+
seedExpiredPinned(MTIME_T0)
|
|
828
|
+
const { refresh, registerProvider } = startWithFakeCtx()
|
|
829
|
+
// 工厂期 R5:expired 不注入——此时该 provider 没有钩子,pi 的刷新碰不到它
|
|
830
|
+
expect(registerProvider).not.toHaveBeenCalled()
|
|
831
|
+
|
|
832
|
+
await tickOnce() // 首拍:mtime 未变,无动作
|
|
833
|
+
expect(registerProvider).not.toHaveBeenCalled()
|
|
834
|
+
expect(refresh).not.toHaveBeenCalled()
|
|
835
|
+
|
|
836
|
+
// 上游目录刷新把条目写成 fresh(mtime 变)→ 本拍对账补注册,再刷新
|
|
837
|
+
rewriteCatalogPinned(MTIME_T1)
|
|
838
|
+
await tickOnce()
|
|
839
|
+
expectReconciledThenRefreshed(registerProvider, refresh, ['Model-A', 'Model-B'])
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
it('跃迁②:工厂期 never-seen(缓存缺失)→ 缓存出现且 fresh → tick 内注册', async () => {
|
|
843
|
+
const { refresh, registerProvider } = startWithFakeCtx()
|
|
844
|
+
expect(registerProvider).not.toHaveBeenCalled()
|
|
845
|
+
|
|
846
|
+
await tickOnce() // 首拍:缓存持续缺失 = 非变化信号
|
|
847
|
+
expect(refresh).not.toHaveBeenCalled()
|
|
848
|
+
|
|
849
|
+
seedCatalogPinned(MTIME_T1) // absent → present
|
|
850
|
+
await tickOnce()
|
|
851
|
+
expectReconciledThenRefreshed(registerProvider, refresh, ['Model-A'])
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
it('跃迁③:工厂期损坏(R2 跳过注入)→ 文件修复 → tick 内重建注入,无需重开会话', async () => {
|
|
855
|
+
writeFileSync(TAiji_CACHE_PATH(), '{ not json')
|
|
856
|
+
utimesSync(TAiji_CACHE_PATH(), MTIME_T0, MTIME_T0)
|
|
857
|
+
const { refresh, registerProvider } = startWithFakeCtx()
|
|
858
|
+
expect(registerProvider).not.toHaveBeenCalled()
|
|
859
|
+
expect(loggerMocks.warn).toHaveBeenCalledWith(expect.stringContaining('catalog cache corrupt'))
|
|
860
|
+
|
|
861
|
+
await tickOnce() // 首拍:损坏文件 mtime 未变,无动作
|
|
862
|
+
expect(refresh).not.toHaveBeenCalled()
|
|
863
|
+
|
|
864
|
+
seedCatalogPinned(MTIME_T1) // 修复(设置页目录刷新重写 / 手工修复)
|
|
865
|
+
await tickOnce()
|
|
866
|
+
expectReconciledThenRefreshed(registerProvider, refresh, ['Model-A'])
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
it('对照:既有 models.json/auth.json 变化但目录缓存未变 → 刷新但不触发对账(无新注册)', async () => {
|
|
870
|
+
seedExpiredPinned(MTIME_T0)
|
|
871
|
+
const { refresh, registerProvider } = startWithFakeCtx()
|
|
872
|
+
await tickOnce() // 首拍建基线
|
|
873
|
+
|
|
874
|
+
// 仅 auth.json 出现(absent → present)→ 既有两条目的刷新路径
|
|
875
|
+
writeFileSync(join(agentDir, 'auth.json'), '{}')
|
|
876
|
+
await tickOnce()
|
|
877
|
+
expectRefreshedOnce(refresh)
|
|
878
|
+
// 目录缓存 mtime 未变 → 三态不可能变化 → 不对账;expired 的 provider 仍不注册
|
|
879
|
+
expect(registerProvider).not.toHaveBeenCalled()
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
it('R1:env 无数据目录 → 工厂期零注册,tick 也不因目录缓存刷新', async () => {
|
|
883
|
+
delete process.env.TAIJI_AGENT_DATA_DIR
|
|
884
|
+
const { refresh, registerProvider } = startWithFakeCtx()
|
|
885
|
+
expect(registerProvider).not.toHaveBeenCalled()
|
|
886
|
+
|
|
887
|
+
await tickOnce()
|
|
888
|
+
await tickOnce()
|
|
889
|
+
expect(refresh).not.toHaveBeenCalled()
|
|
890
|
+
})
|
|
891
|
+
})
|