@lqc123qwe/car-runtime 1.0.0 → 1.2.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 +36 -1
- package/package.json +1 -1
- package/scripts/release-pipeline.ts +8 -2
- package/src/cli.ts +235 -22
- package/src/dx/doctor.ts +54 -0
- package/src/kernel/context.ts +15 -0
- package/src/load/config.ts +129 -0
- package/src/load/sign.ts +60 -0
- package/src/runtime-core/chatStep.ts +121 -0
- package/src/runtime-core/credentials.ts +135 -0
- package/src/runtime-core/errors.ts +47 -0
- package/src/runtime-core/llm.ts +304 -0
- package/src/runtime-core/redaction.ts +94 -0
- package/src/runtime-core/telemetry.ts +333 -0
- package/src/runtime-core/types.ts +79 -0
- package/src/session/indexStore.ts +178 -3
- package/test/s18.spec.ts +9 -0
- package/test/s25-m8-runtime-core.spec.ts +600 -0
- package/test/s26-signature-closeout.spec.ts +349 -0
- package/test/s27-host-load-wiring.spec.ts +134 -0
- package/test/s28-session-index-followup.spec.ts +236 -0
- package/test/s29-otel-producer.spec.ts +252 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M8 · 运行时底座收口测试(s25)——增量 1:注册表 / finishReason 守卫 / RuntimeCore + Effect 可逆
|
|
3
|
+
*
|
|
4
|
+
* 断言全部对齐 src/runtime-core 真实实现(llm.ts / types.ts / errors.ts);
|
|
5
|
+
* 增量 2(credentials/redaction/telemetry)、增量 3(chatStep/openai-compat SSE)随后追加。
|
|
6
|
+
*/
|
|
7
|
+
import { test } from 'node:test'
|
|
8
|
+
import assert from 'node:assert/strict'
|
|
9
|
+
import { AdapterRegistry, RuntimeCore, withFinishReasonGuard } from '../src/runtime-core/llm.ts'
|
|
10
|
+
import type { LlmAdapter, LlmChunk, LlmRequest } from '../src/runtime-core/types.ts'
|
|
11
|
+
import { Context } from '../src/kernel/context.ts'
|
|
12
|
+
|
|
13
|
+
function fakeAdapter(id: string, chunks: LlmChunk[] = []): LlmAdapter {
|
|
14
|
+
return { id, async *chat(_req: LlmRequest) { yield* chunks } }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('M8: AdapterRegistry——注册/默认回退/重复 id 显式报错/未注册显式报错/list/dispose 幂等', () => {
|
|
18
|
+
const reg = new AdapterRegistry()
|
|
19
|
+
assert.throws(() => reg.get(), /CAR-E-LLM-NOADAPTER.*无/, '空注册表显式报错(禁静默降级)')
|
|
20
|
+
const d1 = reg.register(fakeAdapter('openai-compat'))
|
|
21
|
+
assert.equal(reg.get().id, 'openai-compat', '首个注册即默认')
|
|
22
|
+
const d2 = reg.register(fakeAdapter('anthropic'))
|
|
23
|
+
assert.equal(reg.get().id, 'openai-compat', '后注册不改默认')
|
|
24
|
+
assert.equal(reg.get('anthropic').id, 'anthropic', '显式 id 路由')
|
|
25
|
+
assert.throws(() => reg.register(fakeAdapter('openai-compat')), /CAR-E-LLM-DUP.*already registered/, '重复 id 禁静默覆盖')
|
|
26
|
+
assert.throws(() => reg.get('missing'), /CAR-E-LLM-NOADAPTER.*在册:openai-compat, anthropic/, '未注册 id 报错附在册清单')
|
|
27
|
+
assert.deepEqual(reg.list().map(a => a.id), ['openai-compat', 'anthropic'])
|
|
28
|
+
assert.deepEqual(reg.list().map(a => a.isDefault), [true, false])
|
|
29
|
+
d2.dispose()
|
|
30
|
+
assert.throws(() => reg.get('anthropic'), /CAR-E-LLM-NOADAPTER/)
|
|
31
|
+
d2.dispose() // 幂等:二次 dispose 不抛(登记口径)
|
|
32
|
+
assert.equal(reg.list().length, 1)
|
|
33
|
+
const d3 = reg.register(fakeAdapter('anthropic')) // Effect 可逆:dispose 后可重注册
|
|
34
|
+
assert.equal(reg.get('anthropic').id, 'anthropic')
|
|
35
|
+
d3.dispose()
|
|
36
|
+
d1.dispose()
|
|
37
|
+
assert.throws(() => reg.get(), /CAR-E-LLM-NOADAPTER.*无/, '全空后显式报错')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('M8: withFinishReasonGuard——首个定格透传 / 冲突显式抛错(不可变透传契约)/ 同值重复放行 / 无 finish 透传', async () => {
|
|
41
|
+
async function* src(chunks: LlmChunk[]): AsyncIterable<LlmChunk> { yield* chunks }
|
|
42
|
+
const out1: LlmChunk[] = []
|
|
43
|
+
for await (const c of withFinishReasonGuard(src([{ delta: 'a' }, { finishReason: 'stop' }, { delta: 'b' }]))) out1.push(c)
|
|
44
|
+
assert.equal(out1.length, 3, 'chunk 原样透传(finishReason 后续 chunk 不截断)')
|
|
45
|
+
await assert.rejects(
|
|
46
|
+
async () => { for await (const _c of withFinishReasonGuard(src([{ finishReason: 'stop' }, { finishReason: 'length' }]))) { /* 消费 */ } },
|
|
47
|
+
/CAR-E-LLM-FINISH.*已定格为 "stop".*"length"/,
|
|
48
|
+
'冲突 finishReason = 契约违规显式抛(改写/吞没均禁止)',
|
|
49
|
+
)
|
|
50
|
+
const out2: LlmChunk[] = []
|
|
51
|
+
for await (const c of withFinishReasonGuard(src([{ finishReason: 'stop' }, { finishReason: 'stop' }]))) out2.push(c)
|
|
52
|
+
assert.equal(out2.length, 2, '同值重复放行')
|
|
53
|
+
const out3: LlmChunk[] = []
|
|
54
|
+
for await (const c of withFinishReasonGuard(src([{ delta: 'x' }]))) out3.push(c)
|
|
55
|
+
assert.equal(out3.length, 1, '无 finishReason 透传(守卫不注入;AL-05 显式化在适配器层)')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('M8: RuntimeCore.chat——默认路由 / 显式 adapterId / 未注册显式报错', async () => {
|
|
59
|
+
const core = new RuntimeCore()
|
|
60
|
+
const req: LlmRequest = { model: 'm', messages: [{ role: 'user', content: 'hi' }], tools: [] }
|
|
61
|
+
core.registerLlmAdapter(fakeAdapter('openai-compat', [{ delta: 'he' }, { finishReason: 'stop' }]))
|
|
62
|
+
core.registerLlmAdapter(fakeAdapter('anthropic', [{ delta: 'yo' }, { finishReason: 'stop' }]), { default: true })
|
|
63
|
+
const out1: LlmChunk[] = []
|
|
64
|
+
for await (const c of core.chat(req)) out1.push(c)
|
|
65
|
+
assert.equal(out1.length, 2, 'delta chunk + finishReason chunk')
|
|
66
|
+
assert.equal(out1[0]!.delta, 'yo', '无 adapterId → 默认适配器')
|
|
67
|
+
assert.equal(out1[1]!.finishReason, 'stop')
|
|
68
|
+
const out2: LlmChunk[] = []
|
|
69
|
+
for await (const c of core.chat({ ...req, adapterId: 'openai-compat' })) out2.push(c)
|
|
70
|
+
assert.equal(out2[0]!.delta, 'he', '显式 adapterId 路由')
|
|
71
|
+
assert.equal(out2[1]!.finishReason, 'stop')
|
|
72
|
+
await assert.rejects(
|
|
73
|
+
async () => { for await (const _c of core.chat({ ...req, adapterId: 'nope' })) { /* 消费 */ } },
|
|
74
|
+
/CAR-E-LLM-NOADAPTER/,
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('M8: registerLlmAdapter Effect 可逆——disposeRuntime 回卷后适配器出册、可重注册', async () => {
|
|
79
|
+
const core = new RuntimeCore()
|
|
80
|
+
const ctx = new Context()
|
|
81
|
+
core.bindContext(ctx)
|
|
82
|
+
core.registerLlmAdapter(fakeAdapter('openai-compat', [{ finishReason: 'stop' }]), { default: true })
|
|
83
|
+
assert.equal(core.registry.get().id, 'openai-compat')
|
|
84
|
+
const report = await ctx.disposeRuntime()
|
|
85
|
+
assert.equal(report.errors.length, 0, '回卷零错误')
|
|
86
|
+
assert.throws(() => core.registry.get(), /CAR-E-LLM-NOADAPTER/, '回卷后适配器出册(注册即逆变换,POC-2 同源)')
|
|
87
|
+
core.registerLlmAdapter(fakeAdapter('openai-compat', [{ finishReason: 'stop' }]), { default: true })
|
|
88
|
+
assert.equal(core.registry.get().id, 'openai-compat', '回卷后可重注册')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// ==================== 增量 2:credentials / redaction / telemetry ====================
|
|
92
|
+
|
|
93
|
+
import { keychainService, probeKeychainChannel, CredentialService } from '../src/runtime-core/credentials.ts'
|
|
94
|
+
import { redact, StreamRedactor } from '../src/runtime-core/redaction.ts'
|
|
95
|
+
import { createTelemetryFacade } from '../src/runtime-core/telemetry.ts'
|
|
96
|
+
import { readFileSync } from 'node:fs'
|
|
97
|
+
|
|
98
|
+
// F12 三层检测(L2 熵 ≥3.5)下保证命中:连接串形态豁免熵检(s7 同源判据);mongodb 亦在 M8 流式锚点表内
|
|
99
|
+
const SECRET = 'mongodb://' + 'car:S3cretPw9xK2mQ7@cluster0.abc.mongodb.net/db'
|
|
100
|
+
|
|
101
|
+
test('M8: keychainService 命名空间 + 平台通道探测(win32 读通道缺席显式降级)', () => {
|
|
102
|
+
assert.equal(keychainService('openai'), 'car-runtime/openai')
|
|
103
|
+
const win = probeKeychainChannel('win32')
|
|
104
|
+
assert.equal(win.reader, null, 'win32 无零依赖读通道')
|
|
105
|
+
assert.match(win.note, /缺席|显式降级/)
|
|
106
|
+
assert.ok(probeKeychainChannel('darwin').reader, 'darwin 探测构造读取器(调用时才 spawn)')
|
|
107
|
+
assert.ok(probeKeychainChannel('linux').reader, 'linux 探测构造读取器')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('M8: CredentialService.resolve——keychain 命中出 ref(零明文)+ 审计留痕', () => {
|
|
111
|
+
const audits: Array<Record<string, unknown>> = []
|
|
112
|
+
const svc = new CredentialService({ keychainReader: s => (s === 'car-runtime/openai' ? 'sk-keychain-secret-value' : null) })
|
|
113
|
+
const ref = svc.resolve('openai', { audit: e => audits.push(e as Record<string, unknown>) })
|
|
114
|
+
assert.deepEqual(ref, { provider: 'openai', source: 'keychain', origin: 'car-runtime/openai' })
|
|
115
|
+
assert.equal(JSON.stringify(ref).includes('sk-keychain-secret-value'), false, 'ref 不落明文(O-13)')
|
|
116
|
+
assert.equal(audits.length, 1)
|
|
117
|
+
assert.equal(audits[0]!.action, 'resolve')
|
|
118
|
+
assert.equal('value' in audits[0]!, false, '审计事件零明文')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('M8: resolve——全落空 A080001(不重试 + 引导 car doctor)+ env fallback 显式开启语义', () => {
|
|
122
|
+
const svc = new CredentialService({ keychainReader: () => null })
|
|
123
|
+
assert.throws(() => svc.resolve('openai', { env: {} }), (e: unknown) => {
|
|
124
|
+
const err = e as { message: string; userHint?: string }
|
|
125
|
+
assert.match(err.message, /A080001/, '错误码在 message')
|
|
126
|
+
assert.match(err.userHint ?? '', /car doctor/, '用户文案在 userHint 字段(独立于 message)')
|
|
127
|
+
return true
|
|
128
|
+
})
|
|
129
|
+
// env fallback 默认关:env 有值也不取(provider 命名空间 = 适配器 id,'openai-compat')
|
|
130
|
+
assert.throws(() => svc.resolve('openai-compat', { env: { OPENAI_API_KEY: SECRET } }), /A080001/, 'fallback 未显式开启 = 不取 env')
|
|
131
|
+
// 显式开启 → env 命中 + keychain 通道缺席降级留痕(win32 无读通道 = 真缺席;() => null 是空 keychain 非缺席)
|
|
132
|
+
const noChannel = new CredentialService({ platform: 'win32' })
|
|
133
|
+
assert.equal(noChannel.channelStatus.available, false)
|
|
134
|
+
const ref = noChannel.resolve('openai-compat', { env: { OPENAI_API_KEY: SECRET }, allowEnvFallback: true })
|
|
135
|
+
assert.deepEqual(ref, { provider: 'openai-compat', source: 'env', origin: 'OPENAI_API_KEY', degraded: noChannel.channelStatus.note })
|
|
136
|
+
// CAR_ALLOW_ENV_CREDENTIALS=1 等效
|
|
137
|
+
const ref2 = noChannel.resolve('openai-compat', { env: { OPENAI_API_KEY: SECRET, CAR_ALLOW_ENV_CREDENTIALS: '1' } })
|
|
138
|
+
assert.equal(ref2.source, 'env')
|
|
139
|
+
// 未知 provider → CAR_LLM_API_KEY 通用兜底
|
|
140
|
+
const ref3 = noChannel.resolve('unknown-provider', { env: { CAR_LLM_API_KEY: SECRET }, allowEnvFallback: true })
|
|
141
|
+
assert.equal(ref3.origin, 'CAR_LLM_API_KEY')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test('M8: reveal——按需取值 + 审计留痕 + 值消失显式报错 + 值不入错误信息', () => {
|
|
145
|
+
const audits: Array<Record<string, unknown>> = []
|
|
146
|
+
const audit = (e: unknown) => audits.push(e as Record<string, unknown>)
|
|
147
|
+
const svc = new CredentialService({ keychainReader: s => (s === 'car-runtime/openai' ? SECRET : null) })
|
|
148
|
+
const ref = svc.resolve('openai', { audit })
|
|
149
|
+
const value = svc.reveal(ref, { audit })
|
|
150
|
+
assert.equal(value, SECRET)
|
|
151
|
+
assert.equal(audits.length, 2, 'resolve + reveal 各一条')
|
|
152
|
+
assert.equal(audits[1]!.action, 'reveal')
|
|
153
|
+
assert.equal(JSON.stringify(audits).includes(SECRET), false, '审计零明文')
|
|
154
|
+
// 值消失(resolve 后被删)→ 显式报错
|
|
155
|
+
const gone = new CredentialService({ keychainReader: () => null })
|
|
156
|
+
assert.throws(() => gone.reveal({ provider: 'openai', source: 'keychain', origin: 'car-runtime/openai' }), /A080001/)
|
|
157
|
+
// env ref reveal
|
|
158
|
+
const envSvc = new CredentialService({ keychainReader: () => null })
|
|
159
|
+
const v2 = envSvc.reveal({ provider: 'openai', source: 'env', origin: 'OPENAI_API_KEY' }, { env: { OPENAI_API_KEY: SECRET } })
|
|
160
|
+
assert.equal(v2, SECRET)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('M8: redact——遮蔽不漏原文 + 计数 + 幂等', () => {
|
|
164
|
+
const text = `my key is ${SECRET} end`
|
|
165
|
+
const r = redact(text)
|
|
166
|
+
assert.equal(r.redacted.includes(SECRET), false, '原文不残留')
|
|
167
|
+
assert.equal(r.hits.length, 1)
|
|
168
|
+
assert.equal(r.redactedCount, 1)
|
|
169
|
+
assert.equal(redact(r.redacted).redactedCount, 0, '幂等:遮蔽后不再命中')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('M8: StreamRedactor——跨 chunk 命中不泄漏 / 假锚点不误遮 / 尾窗防拼合 / flush 收口', () => {
|
|
173
|
+
// 跨 chunk:锚点在前块,命中体在后块
|
|
174
|
+
const r = new StreamRedactor()
|
|
175
|
+
const out1 = r.push('my key is sk-')
|
|
176
|
+
assert.equal(out1.includes('sk-'), false, '疑似锚点起驻留')
|
|
177
|
+
const out2 = r.push('abcdefghijklmnop'.repeat(2))
|
|
178
|
+
const out3 = r.flush()
|
|
179
|
+
assert.equal((out1 + out2 + out3).includes(SECRET), false, '跨 chunk 命中被遮蔽')
|
|
180
|
+
// 假锚点不误遮:'task-' 含锚点子串但不成命中 → flush 原样放行
|
|
181
|
+
const r2 = new StreamRedactor()
|
|
182
|
+
const o1 = r2.push('a task-')
|
|
183
|
+
const o2 = r2.push(' for the job')
|
|
184
|
+
const o3 = r2.flush()
|
|
185
|
+
assert.equal(o1 + o2 + o3, 'a task- for the job', '假锚点原文放行(不误遮)')
|
|
186
|
+
// 无锚点:尾窗驻留防跨 chunk 拼合
|
|
187
|
+
const r3 = new StreamRedactor()
|
|
188
|
+
const p1 = r3.push('plain text without any anchor here!!')
|
|
189
|
+
assert.equal(p1.length, 20, '放行长度 = 输入 36 - 尾窗 16')
|
|
190
|
+
assert.equal(r3.pendingLength, 16)
|
|
191
|
+
assert.equal(r3.flush(), 'ny anchor here!!', 'flush 收口驻留尾(末 16 字符)')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('M8: 遥测默认关——noop 句柄 + 零出站 + 零积累(可机器断言)', async () => {
|
|
195
|
+
const t = createTelemetryFacade(undefined)
|
|
196
|
+
assert.equal(t.enabled, false)
|
|
197
|
+
t.getTracer().startSpan('op').setAttribute('k', 'v').end()
|
|
198
|
+
t.getMeter().createCounter('c').add(1)
|
|
199
|
+
await t.flush()
|
|
200
|
+
await t.shutdown()
|
|
201
|
+
// 1.2-S3 stats 加法字段(spansSampledOut/queueOverflows)——noop 全零意图不变
|
|
202
|
+
assert.deepEqual(t.stats(), { spansEnded: 0, spansSampledOut: 0, tracesExported: 0, metricsExported: 0, droppedExports: 0, queueOverflows: 0 })
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
test('M8: 遥测显式开——OTLP/HTTP 形状出站 + stats + 端点不可达静默丢弃(BD-05)', async () => {
|
|
206
|
+
const bodies: Array<{ path: string; body: unknown }> = []
|
|
207
|
+
const fetchImpl = (async (url: string | URL | globalThis.Request, init?: RequestInit) => {
|
|
208
|
+
bodies.push({ path: String(url).replace('https://otel.test', ''), body: JSON.parse(String(init!.body)) })
|
|
209
|
+
return new Response('{}', { status: 200 })
|
|
210
|
+
}) as typeof fetch
|
|
211
|
+
const t = createTelemetryFacade({ endpoint: 'https://otel.test', serviceName: 'car-test' }, { fetchImpl })
|
|
212
|
+
assert.equal(t.enabled, true)
|
|
213
|
+
const span = t.getTracer().startSpan('chat', { attributes: { model: 'gpt' } })
|
|
214
|
+
assert.match(span.traceId, /^[0-9a-f]{32}$/)
|
|
215
|
+
span.recordException(new Error('boom')).end()
|
|
216
|
+
t.getMeter().createCounter('car_calls').add(2, { result: 'ok' })
|
|
217
|
+
await t.flush()
|
|
218
|
+
const traces = bodies.find(b => b.path === '/v1/traces')!
|
|
219
|
+
assert.ok(traces, 'traces 出站')
|
|
220
|
+
assert.equal((traces.body as { resource: { attributes: Array<{ key: string; value: { stringValue: string } }> } }).resource.attributes[0]!.value.stringValue, 'car-test')
|
|
221
|
+
assert.ok(bodies.find(b => b.path === '/v1/metrics'), 'metrics 出站')
|
|
222
|
+
assert.equal(t.stats().tracesExported, 1)
|
|
223
|
+
assert.equal(t.stats().metricsExported, 1)
|
|
224
|
+
// 端点不可达:flush resolve 不 reject(BD-05 静默丢弃)
|
|
225
|
+
const down = createTelemetryFacade({ endpoint: 'https://down.test' }, { fetchImpl: (async () => { throw new Error('unreachable') }) as typeof fetch })
|
|
226
|
+
down.getTracer().startSpan('x').end()
|
|
227
|
+
await down.flush()
|
|
228
|
+
assert.equal(down.stats().droppedExports, 1)
|
|
229
|
+
await down.shutdown()
|
|
230
|
+
// 严格解耦静态断言:telemetry.ts 零 import 自 session/*
|
|
231
|
+
const src = readFileSync(new URL('../src/runtime-core/telemetry.ts', import.meta.url), 'utf-8')
|
|
232
|
+
assert.equal(/from\s+'\.\.\/session/.test(src), false, '遥测与审计日志严格解耦(§3.2.M8.5)')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
// ==================== 增量 3:chatStep 集成 + openai-compat 适配器 ====================
|
|
236
|
+
|
|
237
|
+
import { SessionLog } from '../src/session/log.ts'
|
|
238
|
+
import { chatStep } from '../src/runtime-core/chatStep.ts'
|
|
239
|
+
import { createOpenAICompatAdapter } from '../src/runtime-core/llm.ts'
|
|
240
|
+
import { CarM8Error } from '../src/runtime-core/errors.ts'
|
|
241
|
+
|
|
242
|
+
function sseResponse(lines: string[]): Response {
|
|
243
|
+
const enc = new TextEncoder()
|
|
244
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
245
|
+
start(c) { for (const l of lines) c.enqueue(enc.encode(l)); c.close() },
|
|
246
|
+
})
|
|
247
|
+
return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const dataLine = (obj: unknown) => `data: ${JSON.stringify(obj)}\n\n`
|
|
251
|
+
const OK_DONE = [dataLine({ choices: [{ delta: {}, finish_reason: 'stop' }] }), 'data: [DONE]\n\n']
|
|
252
|
+
|
|
253
|
+
async function drain(it: AsyncIterable<unknown>): Promise<unknown[]> {
|
|
254
|
+
const out: unknown[] = []
|
|
255
|
+
for await (const c of it) out.push(c)
|
|
256
|
+
return out
|
|
257
|
+
}
|
|
258
|
+
// ==================== 增量 3 测试体:openai-compat SSE/重试 + chatStep 集成(SQ-07) ====================
|
|
259
|
+
|
|
260
|
+
import type { CredentialService } from '../src/runtime-core/credentials.ts'
|
|
261
|
+
import { credentialMissing } from '../src/runtime-core/errors.ts'
|
|
262
|
+
|
|
263
|
+
test('M8: openai-compat SSE 成功流——零 error chunk(M8-BUG-1 捕获器:AL-05 兜底不得污染已定格流)', async () => {
|
|
264
|
+
const adapter = createOpenAICompatAdapter({
|
|
265
|
+
baseUrl: 'https://api.test/v1',
|
|
266
|
+
fetchImpl: (async () => sseResponse([
|
|
267
|
+
dataLine({ choices: [{ delta: { content: 'he' } }] }),
|
|
268
|
+
dataLine({ choices: [{ delta: { content: 'y' } }] }),
|
|
269
|
+
...OK_DONE,
|
|
270
|
+
])) as typeof fetch,
|
|
271
|
+
})
|
|
272
|
+
const out = await drain(adapter.chat({ model: 'm', messages: [{ role: 'user', content: 'hi' }], tools: [] }))
|
|
273
|
+
assert.equal(out.length, 3, 'delta + delta + finish:无任何追加 error chunk')
|
|
274
|
+
assert.equal((out[0] as LlmChunk).delta, 'he')
|
|
275
|
+
assert.equal((out[out.length - 1] as LlmChunk).finishReason, 'stop')
|
|
276
|
+
assert.ok(out.every(c => (c as LlmChunk).finishReason !== 'error'), '成功流零 error chunk')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('M8: openai-compat——TLS 强制:http baseUrl 构造即抛 CAR-E-LLM-TLS', () => {
|
|
280
|
+
assert.throws(() => createOpenAICompatAdapter({ baseUrl: 'http://api.test/v1' }), /CAR-E-LLM-TLS/)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('M8: openai-compat SSE——tool_calls 增量解析,finish_reason=tool_calls → toolUse 映射', async () => {
|
|
284
|
+
const adapter = createOpenAICompatAdapter({
|
|
285
|
+
baseUrl: 'https://api.test/v1',
|
|
286
|
+
fetchImpl: (async () => sseResponse([
|
|
287
|
+
dataLine({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'get_weather', arguments: '{"city":' } }] } }] }),
|
|
288
|
+
dataLine({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '"北京"}' } }] } }] }),
|
|
289
|
+
dataLine({ choices: [{ delta: {}, finish_reason: 'tool_calls' }] }),
|
|
290
|
+
'data: [DONE]\n\n',
|
|
291
|
+
])) as typeof fetch,
|
|
292
|
+
})
|
|
293
|
+
const out = await drain(adapter.chat({ model: 'm', messages: [{ role: 'user', content: 'w' }], tools: [] }))
|
|
294
|
+
assert.equal(out.length, 3, '两条 toolCallDelta + 一条 finish')
|
|
295
|
+
assert.equal((out[0] as LlmChunk).toolCallDelta?.name, 'get_weather', '首条 toolCallDelta 携带 id/name/args 首段')
|
|
296
|
+
assert.equal((out[0] as LlmChunk).toolCallDelta?.argumentsDelta, '{"city":')
|
|
297
|
+
assert.equal((out[1] as LlmChunk).toolCallDelta?.argumentsDelta, '"北京"}', '次条续传 args 增量')
|
|
298
|
+
const fin = out[out.length - 1] as LlmChunk
|
|
299
|
+
assert.equal(fin.finishReason, 'toolUse', 'tool_calls → toolUse(FINISH_MAP)')
|
|
300
|
+
assert.equal(fin.toolCallDelta, undefined, 'finish chunk 不混载 toolCallDelta')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
test('M8: openai-compat——未映射 finish_reason fail-visible:error chunk + raw 在 message(禁静默改写)', async () => {
|
|
304
|
+
const adapter = createOpenAICompatAdapter({
|
|
305
|
+
baseUrl: 'https://api.test/v1',
|
|
306
|
+
fetchImpl: (async () => sseResponse([
|
|
307
|
+
dataLine({ choices: [{ delta: {}, finish_reason: 'weird_signal' }] }),
|
|
308
|
+
'data: [DONE]\n\n',
|
|
309
|
+
])) as typeof fetch,
|
|
310
|
+
})
|
|
311
|
+
const out = await drain(adapter.chat({ model: 'm', messages: [{ role: 'user', content: 'x' }], tools: [] }))
|
|
312
|
+
assert.equal(out.length, 1, 'fail-visible 单 chunk(sawFinish 命中,无二次兜底)')
|
|
313
|
+
const c = out[0] as LlmChunk
|
|
314
|
+
assert.equal(c.finishReason, 'error')
|
|
315
|
+
assert.equal(c.error?.code, 'B080001')
|
|
316
|
+
assert.match(c.error?.message ?? '', /weird_signal/, '原始终止信号附在 message(不吞没)')
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
test('M8: openai-compat 重试——首块前 5xx 重试 ≤2 次、指数退避注入可观测、成功续流', async () => {
|
|
320
|
+
let attempts = 0
|
|
321
|
+
const sleeps: number[] = []
|
|
322
|
+
const adapter = createOpenAICompatAdapter({
|
|
323
|
+
baseUrl: 'https://api.test/v1',
|
|
324
|
+
retries: 2,
|
|
325
|
+
backoffMs: [7, 13],
|
|
326
|
+
sleep: async ms => { sleeps.push(ms) },
|
|
327
|
+
fetchImpl: (async () => {
|
|
328
|
+
attempts++
|
|
329
|
+
if (attempts < 3) return new Response('boom', { status: 500 })
|
|
330
|
+
return sseResponse([dataLine({ choices: [{ delta: { content: 'ok' } }] }), ...OK_DONE])
|
|
331
|
+
}) as typeof fetch,
|
|
332
|
+
})
|
|
333
|
+
const out = await drain(adapter.chat({ model: 'm', messages: [{ role: 'user', content: 'hi' }], tools: [] }))
|
|
334
|
+
assert.equal(attempts, 3, '2 次重试后第 3 次成功')
|
|
335
|
+
assert.deepEqual(sleeps, [7, 13], '退避按序注入(1s/2s 基线的缩短注入形态)')
|
|
336
|
+
assert.equal((out[0] as LlmChunk).delta, 'ok')
|
|
337
|
+
assert.equal((out[out.length - 1] as LlmChunk).finishReason, 'stop')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
test('M8: openai-compat 重试耗尽——B080001 CarM8Error(retryable=true,尝试计数入 message)', async () => {
|
|
341
|
+
let attempts = 0
|
|
342
|
+
const sleeps: number[] = []
|
|
343
|
+
const adapter = createOpenAICompatAdapter({
|
|
344
|
+
baseUrl: 'https://api.test/v1',
|
|
345
|
+
retries: 1,
|
|
346
|
+
backoffMs: [5],
|
|
347
|
+
sleep: async ms => { sleeps.push(ms) },
|
|
348
|
+
fetchImpl: (async () => { attempts++; return new Response('down', { status: 503 }) }) as typeof fetch,
|
|
349
|
+
})
|
|
350
|
+
await assert.rejects(
|
|
351
|
+
() => drain(adapter.chat({ model: 'm', messages: [{ role: 'user', content: 'hi' }], tools: [] })),
|
|
352
|
+
(e: unknown) => {
|
|
353
|
+
assert.ok(e instanceof CarM8Error, '重试耗尽 = CarM8Error(禁裸 Error)')
|
|
354
|
+
const err = e as CarM8Error
|
|
355
|
+
assert.equal(err.code, 'B080001')
|
|
356
|
+
assert.equal(err.slug, 'CAR-E-LLM-UNREACHABLE')
|
|
357
|
+
assert.equal(err.retryable, true)
|
|
358
|
+
assert.match(err.message, /尝试 2\/2/)
|
|
359
|
+
return true
|
|
360
|
+
},
|
|
361
|
+
)
|
|
362
|
+
assert.equal(attempts, 2, 'retries=1 → 共 2 次尝试')
|
|
363
|
+
assert.deepEqual(sleeps, [5], '耗尽前退避一次')
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test('M8: openai-compat——4xx 业务错不重试:单条 error chunk 收口(凭据不进错误路径)', async () => {
|
|
367
|
+
let attempts = 0
|
|
368
|
+
const adapter = createOpenAICompatAdapter({
|
|
369
|
+
baseUrl: 'https://api.test/v1',
|
|
370
|
+
fetchImpl: (async () => {
|
|
371
|
+
attempts++
|
|
372
|
+
return new Response(JSON.stringify({ error: { message: 'bad key' } }), { status: 401 })
|
|
373
|
+
}) as typeof fetch,
|
|
374
|
+
})
|
|
375
|
+
const out = await drain(adapter.chat({ model: 'm', messages: [{ role: 'user', content: 'hi' }], tools: [] }))
|
|
376
|
+
assert.equal(attempts, 1, '4xx 不重试(§3.5.4 重试条件全集)')
|
|
377
|
+
assert.equal(out.length, 1)
|
|
378
|
+
const c = out[0] as LlmChunk
|
|
379
|
+
assert.equal(c.finishReason, 'error')
|
|
380
|
+
assert.equal(c.error?.code, 'B080001')
|
|
381
|
+
assert.match(c.error?.message ?? '', /401/)
|
|
382
|
+
assert.doesNotMatch(c.error?.message ?? '', /sk-/, 'Bearer 值不入错误信息')
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
test('M8: openai-compat——流中途失败不重试(已消费 chunk 不可重放)→ error chunk 收口', async () => {
|
|
386
|
+
let attempts = 0
|
|
387
|
+
const adapter = createOpenAICompatAdapter({
|
|
388
|
+
baseUrl: 'https://api.test/v1',
|
|
389
|
+
retries: 2,
|
|
390
|
+
backoffMs: [5, 5],
|
|
391
|
+
sleep: async () => {},
|
|
392
|
+
fetchImpl: (async () => {
|
|
393
|
+
attempts++
|
|
394
|
+
const enc = new TextEncoder()
|
|
395
|
+
let pulled = 0
|
|
396
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
397
|
+
pull(c) {
|
|
398
|
+
pulled++
|
|
399
|
+
if (pulled === 1) c.enqueue(enc.encode(dataLine({ choices: [{ delta: { content: 'part' } }] })))
|
|
400
|
+
else c.error(new Error('connection reset'))
|
|
401
|
+
},
|
|
402
|
+
})
|
|
403
|
+
return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } })
|
|
404
|
+
}) as typeof fetch,
|
|
405
|
+
})
|
|
406
|
+
const out = await drain(adapter.chat({ model: 'm', messages: [{ role: 'user', content: 'hi' }], tools: [] }))
|
|
407
|
+
assert.equal(attempts, 1, '流中途失败不可重放(重试不产生额外副作用)')
|
|
408
|
+
assert.equal(out.length, 2, '已消费 delta + error 收口')
|
|
409
|
+
assert.equal((out[0] as LlmChunk).delta, 'part')
|
|
410
|
+
const last = out[out.length - 1] as LlmChunk
|
|
411
|
+
assert.equal(last.finishReason, 'error')
|
|
412
|
+
assert.equal(last.error?.code, 'B080001')
|
|
413
|
+
assert.match(last.error?.message ?? '', /connection reset/)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test('M8: openai-compat 凭据门——resolve→reveal 注入 Bearer;A080001 首块前传播且 fetch 不发', async () => {
|
|
417
|
+
const calls: string[] = []
|
|
418
|
+
const cred = {
|
|
419
|
+
resolve: (provider: string) => { calls.push('resolve:' + provider); return { provider, source: 'keychain', origin: 'car-runtime/openai' } },
|
|
420
|
+
reveal: (ref: { provider: string }) => { calls.push('reveal:' + ref.provider); return 'sk-test-secret-value' },
|
|
421
|
+
} as unknown as CredentialService
|
|
422
|
+
let sawAuth = ''
|
|
423
|
+
const adapter = createOpenAICompatAdapter({
|
|
424
|
+
baseUrl: 'https://api.test/v1',
|
|
425
|
+
provider: 'openai',
|
|
426
|
+
credentials: cred,
|
|
427
|
+
fetchImpl: (async (_url: string | URL | Request, init?: RequestInit) => {
|
|
428
|
+
sawAuth = String((init?.headers as Record<string, string>)?.authorization ?? '')
|
|
429
|
+
return sseResponse(OK_DONE)
|
|
430
|
+
}) as typeof fetch,
|
|
431
|
+
})
|
|
432
|
+
const out = await drain(adapter.chat({ model: 'm', messages: [{ role: 'user', content: 'hi' }], tools: [] }))
|
|
433
|
+
assert.deepEqual(calls, ['resolve:openai', 'reveal:openai'], 'resolve → reveal 顺序(SQ-07 #3)')
|
|
434
|
+
assert.equal(sawAuth, 'Bearer sk-test-secret-value')
|
|
435
|
+
assert.equal((out[out.length - 1] as LlmChunk).finishReason, 'stop')
|
|
436
|
+
|
|
437
|
+
// 凭据全落空:A080001 首块前抛出(CarM8Error,不重试,userHint 引导 car doctor),fetch 零发出
|
|
438
|
+
const missing = {
|
|
439
|
+
resolve: () => { throw credentialMissing('openai', ['keychain', 'env']) },
|
|
440
|
+
reveal: () => { throw new Error('不应走到 reveal') },
|
|
441
|
+
} as unknown as CredentialService
|
|
442
|
+
let fetched = 0
|
|
443
|
+
const adapter2 = createOpenAICompatAdapter({
|
|
444
|
+
baseUrl: 'https://api.test/v1',
|
|
445
|
+
provider: 'openai',
|
|
446
|
+
credentials: missing,
|
|
447
|
+
fetchImpl: (async () => { fetched++; return sseResponse(OK_DONE) }) as typeof fetch,
|
|
448
|
+
})
|
|
449
|
+
await assert.rejects(
|
|
450
|
+
() => drain(adapter2.chat({ model: 'm', messages: [{ role: 'user', content: 'hi' }], tools: [] })),
|
|
451
|
+
(e: unknown) => {
|
|
452
|
+
assert.ok(e instanceof CarM8Error)
|
|
453
|
+
assert.equal((e as CarM8Error).code, 'A080001')
|
|
454
|
+
assert.equal((e as CarM8Error).retryable, false, 'A080001 不重试')
|
|
455
|
+
assert.match((e as CarM8Error).userHint ?? '', /car doctor/, '用户文案引导 car doctor')
|
|
456
|
+
return true
|
|
457
|
+
},
|
|
458
|
+
)
|
|
459
|
+
assert.equal(fetched, 0, '凭据缺失不发请求')
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
// ---------- chatStep 集成(SQ-07 逐行) ----------
|
|
463
|
+
|
|
464
|
+
test('M8: chatStep 快乐路径——投影请求 → 流式消费 → assistant 落 M7 → stop 交 M4', async () => {
|
|
465
|
+
const log = new SessionLog('S-m8chat')
|
|
466
|
+
log.append('user', 'user', 't1', '天气如何?')
|
|
467
|
+
log.snapshotModelRequest()
|
|
468
|
+
const core = new RuntimeCore()
|
|
469
|
+
core.registerLlmAdapter({
|
|
470
|
+
id: 'openai-compat',
|
|
471
|
+
async *chat(req: LlmRequest) {
|
|
472
|
+
assert.deepEqual(req.messages, [{ role: 'user', content: '天气如何?' }], '请求消息 = deriveMessages 投影(Model-visible means logged)')
|
|
473
|
+
assert.equal(req.metadata?.sessionId, 'S-m8chat', 'metadata 携带 sessionId/turnId/traceId')
|
|
474
|
+
assert.equal(req.metadata?.turnId, 't1')
|
|
475
|
+
yield { delta: '北京晴,' }
|
|
476
|
+
yield { delta: '26°C' }
|
|
477
|
+
yield { finishReason: 'stop' }
|
|
478
|
+
},
|
|
479
|
+
} as LlmAdapter)
|
|
480
|
+
const step = await chatStep({ core, log, turnId: 't1', model: 'gpt-test', tools: [] })
|
|
481
|
+
assert.equal(step.stopReason, 'stop')
|
|
482
|
+
assert.equal(step.text, '北京晴,26°C')
|
|
483
|
+
assert.equal(step.secretsRedacted, 0)
|
|
484
|
+
assert.equal(log.events.length, 2, 'user + assistant 两事件')
|
|
485
|
+
const ev = log.events[log.events.length - 1]!
|
|
486
|
+
assert.equal(ev.kind, 'assistant')
|
|
487
|
+
assert.equal(ev.actor, 'model')
|
|
488
|
+
assert.equal(ev.payload, '北京晴,26°C', 'assistant 文本落 M7')
|
|
489
|
+
assert.equal(ev.meta?.secretsRedacted, 0, '计数留痕 meta(S15 同名口径)')
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
test('M8: chatStep 脱敏——流内密钥经 StreamRedactor 遮蔽后落 M7(零明文)+ 兜底复扫计数', async () => {
|
|
493
|
+
const SECRET = 'sk-' + 'a1b2c3d4e5f6'.repeat(2) // 运行时拼接:源文件不含密钥形字面量(secrets-scan 纪律)
|
|
494
|
+
const log = new SessionLog('S-m8redact')
|
|
495
|
+
log.append('user', 'user', 't2', '帮我看看')
|
|
496
|
+
log.snapshotModelRequest()
|
|
497
|
+
const core = new RuntimeCore()
|
|
498
|
+
core.registerLlmAdapter({
|
|
499
|
+
id: 'openai-compat',
|
|
500
|
+
async *chat() {
|
|
501
|
+
yield { delta: '你的 key 是 ' }
|
|
502
|
+
yield { delta: SECRET }
|
|
503
|
+
yield { delta: ' 请轮换' }
|
|
504
|
+
yield { finishReason: 'stop' }
|
|
505
|
+
},
|
|
506
|
+
} as LlmAdapter)
|
|
507
|
+
const step = await chatStep({ core, log, turnId: 't2', model: 'm', tools: [] })
|
|
508
|
+
assert.equal((step.text ?? '').includes(SECRET), false, '结果文本零明文')
|
|
509
|
+
assert.equal(step.secretsRedacted, 0, '流式遮蔽生效 → 兜底复扫零残留(>0 = 边界逃逸审计信号)')
|
|
510
|
+
const ev = log.events[log.events.length - 1]!
|
|
511
|
+
assert.equal(JSON.stringify(ev.payload).includes(SECRET), false, '落 M7 前已遮蔽')
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
test('M8: chatStep——N1 失守显式拒绝(快照与投影失配 = 带病请求不发模型)', async () => {
|
|
515
|
+
const log = new SessionLog('S-m8n1')
|
|
516
|
+
log.append('user', 'user', 't3', 'q')
|
|
517
|
+
log.snapshotModelRequest()
|
|
518
|
+
;(log as { events: unknown[] }).events.splice(0, 1) // 测试注入:前缀漂移(重放/篡改形态)
|
|
519
|
+
const core = new RuntimeCore()
|
|
520
|
+
let called = false
|
|
521
|
+
core.registerLlmAdapter({
|
|
522
|
+
id: 'openai-compat',
|
|
523
|
+
async *chat() { called = true; yield { finishReason: 'stop' } },
|
|
524
|
+
} as LlmAdapter)
|
|
525
|
+
await assert.rejects(() => chatStep({ core, log, turnId: 't3', model: 'm', tools: [] }), /CAR-E-N1.*atSeq=1/)
|
|
526
|
+
assert.equal(called, false, 'N1 失守不发出模型请求')
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
test('M8: chatStep——length 收口:半截 args 不解析(ADR-001),truncatedTools 只有名字', async () => {
|
|
530
|
+
const log = new SessionLog('S-m8len')
|
|
531
|
+
log.append('user', 'user', 't4', 'q')
|
|
532
|
+
log.snapshotModelRequest()
|
|
533
|
+
const core = new RuntimeCore()
|
|
534
|
+
core.registerLlmAdapter({
|
|
535
|
+
id: 'openai-compat',
|
|
536
|
+
async *chat() {
|
|
537
|
+
yield { toolCallDelta: { index: 0, id: 'call_9', name: 'run_query', argumentsDelta: '{"sql": "SE' } }
|
|
538
|
+
yield { finishReason: 'length' }
|
|
539
|
+
},
|
|
540
|
+
} as LlmAdapter)
|
|
541
|
+
const step = await chatStep({ core, log, turnId: 't4', model: 'm', tools: [] })
|
|
542
|
+
assert.equal(step.stopReason, 'length')
|
|
543
|
+
assert.deepEqual(step.truncatedTools, [{ id: 'call_9', tool: 'run_query' }])
|
|
544
|
+
assert.equal(step.toolCalls, undefined, 'length 不解析 args')
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
test('M8: chatStep——finishReason error/aborted → B080001 抛出(BD-04 收口由 runTurn 承接)', async () => {
|
|
548
|
+
const log = new SessionLog('S-m8err')
|
|
549
|
+
log.append('user', 'user', 't5', 'q')
|
|
550
|
+
log.snapshotModelRequest()
|
|
551
|
+
const core = new RuntimeCore()
|
|
552
|
+
core.registerLlmAdapter({
|
|
553
|
+
id: 'openai-compat',
|
|
554
|
+
async *chat() {
|
|
555
|
+
yield { delta: 'x' }
|
|
556
|
+
yield { finishReason: 'error', error: { code: 'B080001', message: 'provider 内部错误' } }
|
|
557
|
+
},
|
|
558
|
+
} as LlmAdapter)
|
|
559
|
+
await assert.rejects(
|
|
560
|
+
() => chatStep({ core, log, turnId: 't5', model: 'm', tools: [] }),
|
|
561
|
+
(e: unknown) => {
|
|
562
|
+
assert.ok(e instanceof CarM8Error)
|
|
563
|
+
assert.equal((e as CarM8Error).code, 'B080001')
|
|
564
|
+
assert.match((e as CarM8Error).message, /provider 内部错误/, 'errorDetail 随异常携带')
|
|
565
|
+
return true
|
|
566
|
+
},
|
|
567
|
+
)
|
|
568
|
+
// aborted 同映射:'aborted' → 'error' → 同收口
|
|
569
|
+
const log2 = new SessionLog('S-m8abort')
|
|
570
|
+
log2.append('user', 'user', 't5b', 'q')
|
|
571
|
+
log2.snapshotModelRequest()
|
|
572
|
+
const core2 = new RuntimeCore()
|
|
573
|
+
core2.registerLlmAdapter({ id: 'openai-compat', async *chat() { yield { finishReason: 'aborted' } } } as LlmAdapter)
|
|
574
|
+
await assert.rejects(() => chatStep({ core: core2, log: log2, turnId: 't5b', model: 'm', tools: [] }), /CAR-E-LLM-UNREACHABLE/)
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
test('M8: SQ-07 端到端——openai-compat SSE tool_calls → chatStep 聚合解析 → toolUse 交 M4', async () => {
|
|
578
|
+
const log = new SessionLog('S-m8sq07')
|
|
579
|
+
log.append('user', 'user', 't6', '北京天气?')
|
|
580
|
+
log.snapshotModelRequest()
|
|
581
|
+
const adapter = createOpenAICompatAdapter({
|
|
582
|
+
baseUrl: 'https://api.test/v1',
|
|
583
|
+
fetchImpl: (async () => sseResponse([
|
|
584
|
+
dataLine({ choices: [{ delta: { tool_calls: [{ index: 0, id: 'call_1', function: { name: 'get_weather', arguments: '{"city":' } }] } }] }),
|
|
585
|
+
dataLine({ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '"北京"}' } }] } }] }),
|
|
586
|
+
dataLine({ choices: [{ delta: {}, finish_reason: 'tool_calls' }] }),
|
|
587
|
+
'data: [DONE]\n\n',
|
|
588
|
+
])) as typeof fetch,
|
|
589
|
+
})
|
|
590
|
+
const core = new RuntimeCore()
|
|
591
|
+
core.registerLlmAdapter(adapter)
|
|
592
|
+
const step = await chatStep({
|
|
593
|
+
core, log, turnId: 't6', model: 'gpt-4o',
|
|
594
|
+
tools: [{ name: 'get_weather', declaredSideEffect: 'readonly' }],
|
|
595
|
+
})
|
|
596
|
+
assert.equal(step.stopReason, 'toolUse')
|
|
597
|
+
assert.deepEqual(step.toolCalls, [{ id: 'call_1', tool: 'get_weather', args: { city: '北京' } }], '跨 chunk args 聚合后整体解析')
|
|
598
|
+
assert.equal(step.secretsRedacted, 0)
|
|
599
|
+
assert.equal(log.events.length, 1, '空文本不产 assistant 事件;toolCall 事件归 runTurn(本层不重复)')
|
|
600
|
+
})
|