@lqc123qwe/car-runtime 1.3.0 → 1.4.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.
@@ -0,0 +1,329 @@
1
+ /**
2
+ * 1.4-S1~S5 · 真实模型接线(S31):声明面统一 / system 落链 / llm.* 配置 / loopback 豁免 /
3
+ * 取消透传 / CarM8Error 保形 / car run 装配重做(D-13)+ 多步真 E2E
4
+ *
5
+ * 覆盖(1.4-迭代规划 W1-1~W1-7):
6
+ * - toToolDefinitions:schema 透传 / T-22 缺省 write 收口
7
+ * - 'system' 事件:投影 role:'system'(§3.5.5 新可见输入 = 新事件类型)
8
+ * - llm.* 配置:校验 fail-visible / 优先级 flag > env > config / CAR_LLM_* env
9
+ * - D-12a:http loopback 豁免(127.0.0.1/localhost/[::1])+ 非 loopback http 拒绝
10
+ * - 取消(D-19):TurnAborted 首次实抛(finish 检查前序)+ runTurn 收口 aborted
11
+ * - CarM8Error 保形:A080001 userHint/retryable 到 turnEnd
12
+ * - 多步真 E2E:本地 loopback 假 SSE 服务——system 出站 / 工具 schema 出站 / Bearer 凭据 /
13
+ * 第二请求含 toolResult / 声明面=授权面;car run spawn 全链 + 无配置 exit 2
14
+ */
15
+ import { test } from 'node:test'
16
+ import assert from 'node:assert/strict'
17
+ import { spawn } from 'node:child_process'
18
+ import { createServer, type Server } from 'node:http'
19
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
20
+ import { tmpdir } from 'node:os'
21
+ import { join, dirname } from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
23
+ import { toToolDefinitions } from '../src/runtime-core/tools.ts'
24
+ import { createOpenAICompatAdapter, RuntimeCore } from '../src/runtime-core/llm.ts'
25
+ import { chatStep } from '../src/runtime-core/chatStep.ts'
26
+ import { CredentialService } from '../src/runtime-core/credentials.ts'
27
+ import { runTurn } from '../src/loop/stop.ts'
28
+ import { Context } from '../src/kernel/context.ts'
29
+ import { SessionLog } from '../src/session/log.ts'
30
+ import { mergeLlmConfig, loadCarConfig } from '../src/load/config.ts'
31
+ import { doctorModelReadiness } from '../src/dx/doctor.ts'
32
+
33
+ // 密钥扫描口径(generic-secret-assign):夹具凭据经变量注入(赋值行无引号字面量);值非真实凭据
34
+ const TEST_KEY = 'test-key-123'
35
+
36
+ const CLI = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'cli.ts')
37
+
38
+ function withTempDir(name: string, fn: (dir: string) => Promise<void> | void): Promise<void> {
39
+ const dir = mkdtempSync(join(tmpdir(), `car-s31-${name}-`))
40
+ try {
41
+ const r = fn(dir)
42
+ const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } }
43
+ if (r instanceof Promise) return r.finally(cleanup)
44
+ cleanup()
45
+ return Promise.resolve()
46
+ } catch (e) {
47
+ try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ }
48
+ throw e
49
+ }
50
+ }
51
+
52
+ /** SSE 流响应(openai-compat 形态) */
53
+ function sse(chunks: object[]): string {
54
+ return chunks.map(c => `data: ${JSON.stringify(c)}\n\n`).join('') + 'data: [DONE]\n\n'
55
+ }
56
+
57
+ const TOOL_CALL_SSE = (name: string, argsJson: string, id: string) => sse([
58
+ { choices: [{ delta: { role: 'assistant', content: '' } }] },
59
+ { choices: [{ delta: { tool_calls: [{ index: 0, id, function: { name, arguments: argsJson.slice(0, 3) } }] } }] },
60
+ { choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: argsJson.slice(3) } }] } }] },
61
+ { choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
62
+ ])
63
+ const TEXT_SSE = (text: string) => sse([
64
+ { choices: [{ delta: { role: 'assistant', content: '' } }] },
65
+ { choices: [{ delta: { content: text } }] },
66
+ { choices: [{ delta: {}, finish_reason: 'stop' }] },
67
+ ])
68
+
69
+ /** 本地假 OpenAI 兼容服务:捕获请求体,按脚本回放 SSE */
70
+ function fakeOpenAI(script: Array<(body: any) => string>): Promise<{ server: Server; url: string; bodies: any[]; headers: any[]; done: Promise<void> }> {
71
+ const bodies: any[] = []
72
+ const headers: any[] = []
73
+ let idx = 0
74
+ let finish: (v: void) => void = () => {}
75
+ const done = new Promise<void>(r => { finish = r })
76
+ const server = createServer((req, res) => {
77
+ let raw = ''
78
+ req.on('data', c => { raw += c })
79
+ req.on('end', () => {
80
+ bodies.push(JSON.parse(raw))
81
+ headers.push(req.headers)
82
+ res.writeHead(200, { 'content-type': 'text/event-stream' })
83
+ const step = script[Math.min(idx, script.length - 1)]
84
+ idx++
85
+ res.end(step(bodies[bodies.length - 1]))
86
+ if (idx >= script.length) finish()
87
+ })
88
+ })
89
+ const url = new Promise<string>(r => server.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(server.address() as { port: number }).port}/v1`)))
90
+ return url.then(u => ({ server, url: u, bodies, headers, done }))
91
+ }
92
+
93
+ // ==================== S1:声明面统一 ====================
94
+
95
+ test('S31: toToolDefinitions——description/parameters 透传 + T-22 缺省 write 收口 + 声明面=授权面', () => {
96
+ const map = new Map<string, any>([
97
+ ['full', { declaredSideEffect: 'readonly' as const, run: async () => 1, description: '读工具', parameters: { type: 'object', properties: { q: { type: 'string' } } } }],
98
+ ['bare', { declaredSideEffect: 'write' as const, run: async () => 2 }],
99
+ ])
100
+ const defs = toToolDefinitions(map)
101
+ assert.deepEqual(defs, [
102
+ { name: 'full', description: '读工具', parameters: { type: 'object', properties: { q: { type: 'string' } } }, declaredSideEffect: 'readonly' },
103
+ { name: 'bare', declaredSideEffect: 'write' },
104
+ ])
105
+ // declaredSideEffect 不出站的口径由 llm.ts 出站映射承载(llm.ts:237 只带 name/description/parameters)
106
+ })
107
+
108
+ // ==================== S2:system 落链 ====================
109
+
110
+ test('S31: system 事件——落链 → deriveMessages 投影 role:system(§3.5.5 新可见输入 = 新事件类型)', () => {
111
+ const log = new SessionLog('S-sys-1')
112
+ log.append('runtime', 'system', 'system', '你是测试助手')
113
+ log.append('user', 'user', 'T0', 'hi')
114
+ const msgs = log.deriveMessages()
115
+ assert.deepEqual(msgs[0], { role: 'system', content: '你是测试助手' })
116
+ assert.equal(log.verifyChain(), null, '哈希链含 system 事件仍完整')
117
+ assert.equal(log.assertModelVisibleLogged().ok, true, 'N1 含 system 事件成立')
118
+ })
119
+
120
+ // ==================== S3:llm.* 配置通道 ====================
121
+
122
+ test('S31: llm.* 配置——校验 fail-visible / 优先级 flag > env > config / CAR_LLM_* env 通道', () => {
123
+ withTempDir('llm-config', dir => {
124
+ // 未知键 / 类型错 fail-visible
125
+ writeFileSync(join(dir, 'bad.json'), JSON.stringify({ llm: { temperature: 0.7 } }))
126
+ assert.match(loadCarConfig({ explicitPath: join(dir, 'bad.json') }).error!, /未知键 llm\.temperature/)
127
+ writeFileSync(join(dir, 'bad2.json'), JSON.stringify({ llm: { maxTokens: 1.5 } }))
128
+ assert.match(loadCarConfig({ explicitPath: join(dir, 'bad2.json') }).error!, /llm\.maxTokens 必须为正整数/)
129
+ // 有效配置
130
+ const good = join(dir, 'good.json')
131
+ writeFileSync(good, JSON.stringify({ llm: { baseUrl: 'https://cfg.example/v1', model: 'cfg-model', maxTokens: 100, allowEnvFallback: true } }))
132
+ const cfg = loadCarConfig({ explicitPath: good }).config
133
+ // 优先级:config < env < flag
134
+ const c = mergeLlmConfig({}, cfg)
135
+ assert.equal(c.baseUrl, 'https://cfg.example/v1')
136
+ assert.equal(c.model, 'cfg-model')
137
+ assert.equal(c.allowEnvFallback, true)
138
+ const e = mergeLlmConfig({ CAR_LLM_BASE_URL: 'https://env.example/v1', CAR_LLM_MODEL: 'env-model' }, cfg)
139
+ assert.equal(e.baseUrl, 'https://env.example/v1')
140
+ assert.equal(e.model, 'env-model')
141
+ const f = mergeLlmConfig({ CAR_LLM_MODEL: 'env-model' }, cfg, { baseUrl: 'https://flag.example/v1', model: 'flag-model', maxTokens: 7 })
142
+ assert.equal(f.baseUrl, 'https://flag.example/v1')
143
+ assert.equal(f.model, 'flag-model')
144
+ assert.equal(f.maxTokens, 7)
145
+ // CAR_ALLOW_ENV_CREDENTIALS 显式意见压过配置(=0 关)
146
+ assert.equal(mergeLlmConfig({ CAR_ALLOW_ENV_CREDENTIALS: '0' }, cfg).allowEnvFallback, false)
147
+ })
148
+ })
149
+
150
+ test('S31: doctorModelReadiness——configured/not-found/env 三态(值不打印红线)', () => {
151
+ withTempDir('doctor-model', dir => {
152
+ const d1 = doctorModelReadiness({ env: {}, cwd: dir })
153
+ assert.equal(d1.configured, false)
154
+ assert.equal(d1.credential, 'not-found')
155
+ assert.match(d1.detail, /未配置/)
156
+ const d2 = doctorModelReadiness({
157
+ env: { CAR_LLM_BASE_URL: 'https://x/v1', CAR_LLM_MODEL: 'm', OPENAI_API_KEY: TEST_KEY, CAR_ALLOW_ENV_CREDENTIALS: '1' },
158
+ cwd: dir,
159
+ })
160
+ assert.equal(d2.configured, true)
161
+ assert.equal(d2.credential, 'env')
162
+ assert.equal(d2.envFallback, 'on')
163
+ assert.equal(d2.detail.includes(TEST_KEY), false, '凭据值永不打印')
164
+ })
165
+ })
166
+
167
+ // ==================== S4:D-12a + 取消 + CarM8Error ====================
168
+
169
+ test('S31: D-12a loopback 豁免——http 仅 127.0.0.1/localhost/[::1];非 loopback http 拒绝', () => {
170
+ assert.ok(createOpenAICompatAdapter({ baseUrl: 'http://127.0.0.1:1234/v1' }), 'loopback http 允许')
171
+ assert.ok(createOpenAICompatAdapter({ baseUrl: 'http://localhost:1234/v1' }), 'localhost http 允许')
172
+ assert.ok(createOpenAICompatAdapter({ baseUrl: 'http://[::1]:1234/v1' }), 'IPv6 loopback http 允许')
173
+ assert.throws(() => createOpenAICompatAdapter({ baseUrl: 'http://10.0.0.1:8080/v1' }), /CAR-E-LLM-TLS/, '非 loopback http 拒绝')
174
+ assert.throws(() => createOpenAICompatAdapter({ baseUrl: 'http://example.com/v1' }), /CAR-E-LLM-TLS/)
175
+ assert.ok(createOpenAICompatAdapter({ baseUrl: 'https://api.example.com/v1' }), 'https 任意域允许')
176
+ })
177
+
178
+ test('S31: 取消(D-19)——TurnAborted 首次实抛:pre-aborted 走 finish 前检查;runTurn 收口 aborted', async () => {
179
+ await withTempDir('cancel', async dir => {
180
+ const log = new SessionLog('S-cancel')
181
+ log.append('runtime', 'system', 'system', 'sys')
182
+ log.append('user', 'user', 'T0', 'hi')
183
+ const core = new RuntimeCore()
184
+ const signal = { aborted: true } // pre-aborted:适配器 attempt 前静默收口(零 chunk)
185
+ const tools = new Map<string, any>([['t', { declaredSideEffect: 'readonly', run: async () => 'ok' }]])
186
+ // pre-aborted:适配器 attempt 前静默收口(零 chunk)→ chatStep finish 检查前抛 TurnAborted
187
+ const adapter = createOpenAICompatAdapter({ baseUrl: 'http://127.0.0.1:1/v1', fetchImpl: (async () => { throw new Error('不应发起请求') }) as typeof fetch })
188
+ core.registerLlmAdapter(adapter)
189
+ const r = await runTurn({
190
+ log, turnId: 'T0', tools, signal,
191
+ model: () => chatStep({ core, log, turnId: 'T0', model: 'm', tools: toToolDefinitions(tools), signal }),
192
+ })
193
+ assert.equal(r.reason, 'aborted', 'runTurn 既有 catch 收口 aborted')
194
+ const turnEnd = log.events.find(e => e.kind === 'turnEnd')!
195
+ assert.equal((turnEnd.meta as any)?.reason, 'aborted')
196
+ assert.equal(log.events.some(e => e.kind === 'assistant'), false, '无半截 assistant 落链')
197
+ })
198
+ })
199
+
200
+ test('S31: CarM8Error 保形——A080001 code/userHint/retryable 进 turnEnd(引导文案可达用户)', async () => {
201
+ await withTempDir('m8-error', async dir => {
202
+ const log = new SessionLog('S-m8err')
203
+ log.append('user', 'user', 'T0', 'hi')
204
+ const cred = new CredentialService()
205
+ const core = new RuntimeCore(cred)
206
+ core.registerLlmAdapter(createOpenAICompatAdapter({ baseUrl: 'http://127.0.0.1:1/v1', credentials: cred, provider: 'openai-compat' }))
207
+ const r = await runTurn({
208
+ log, turnId: 'T0',
209
+ tools: new Map<string, any>(),
210
+ model: () => chatStep({ core, log, turnId: 'T0', model: 'm', tools: [] }),
211
+ })
212
+ assert.equal(r.reason, 'error')
213
+ const turnEnd = log.events.find(e => e.kind === 'turnEnd')!
214
+ const meta = turnEnd.meta as any
215
+ assert.equal(meta.code, 'A080001')
216
+ assert.match(meta.userHint, /car doctor/, '引导文案进 turnEnd(1.4 前被 String(e) 拍平)')
217
+ assert.equal(meta.retryable, false)
218
+ })
219
+ })
220
+
221
+ // ==================== S5:多步真 E2E(真 HTTP + 真装配) ====================
222
+
223
+ test('S31: 多步真 E2E——system 出站 / 工具 schema 出站 / Bearer 凭据 / 第二请求含 toolResult / 声明面=授权面', async () => {
224
+ await withTempDir('multistep', async dir => {
225
+ const { server, url, bodies, headers, done } = await fakeOpenAI([
226
+ () => TOOL_CALL_SSE('echo_tool', '{"text":"hi"}', 'call_1'),
227
+ () => TEXT_SSE('完成了'),
228
+ ])
229
+ try {
230
+ const log = new SessionLog('S-e2e')
231
+ log.append('runtime', 'system', 'system', '你是集成测试助手')
232
+ log.append('user', 'user', 'T0', '请调用 echo_tool')
233
+ const cred = new CredentialService({ keychainReader: s => (s === 'car-runtime/openai-compat' ? 'test-key-123' : null) })
234
+ const core = new RuntimeCore(cred)
235
+ const ctx = new Context()
236
+ core.bindContext(ctx)
237
+ core.registerLlmAdapter(createOpenAICompatAdapter({
238
+ baseUrl: url, credentials: cred, provider: 'openai-compat',
239
+ }), { default: true })
240
+ const tools = new Map<string, any>([[
241
+ 'echo_tool',
242
+ { declaredSideEffect: 'readonly', description: '回声工具', parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] }, run: async (a: any) => 'echo:' + a.text },
243
+ ]])
244
+ const r = await runTurn({
245
+ log, turnId: 'T0', tools,
246
+ model: () => chatStep({ core, log, turnId: 'T0', model: 'test-model', tools: toToolDefinitions(tools) }),
247
+ })
248
+ assert.equal(r.reason, 'completed')
249
+ assert.equal(r.steps, 2, '两步:toolUse → 工具执行 → stop')
250
+ await done
251
+ // 请求 1:system 消息出站(§3.5.5)+ 工具 schema 出站 + Bearer 凭据
252
+ assert.equal(bodies[0].messages[0].role, 'system')
253
+ assert.equal(bodies[0].messages[0].content, '你是集成测试助手')
254
+ assert.equal(bodies[0].tools.length, 1)
255
+ assert.equal(bodies[0].tools[0].function.name, 'echo_tool')
256
+ assert.deepEqual(bodies[0].tools[0].function.parameters, { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] })
257
+ assert.equal(bodies[0].tools[0].function.description, '回声工具')
258
+ assert.equal(headers[0].authorization, 'Bearer ' + TEST_KEY, '凭据经 CredentialService→Bearer')
259
+ assert.equal(JSON.stringify(bodies[0]).includes(TEST_KEY), false, '凭据值不出站到消息体')
260
+ // 请求 2:第二请求含 toolResult 消息(executeBatch 落链 → 投影 → 出站)
261
+ const toolMsg = bodies[1].messages.find((m: any) => m.role === 'tool')
262
+ assert.ok(toolMsg, '第二请求含 tool 消息')
263
+ assert.equal(toolMsg.tool_call_id, 'call_1')
264
+ assert.equal(toolMsg.content, '"echo:hi"')
265
+ // 落链面:assistant/toolCall/toolResult 齐备(N1)
266
+ assert.equal(log.assertModelVisibleLogged().ok, true)
267
+ const final = log.deriveMessages().at(-1)!
268
+ assert.equal(final.role, 'assistant')
269
+ assert.equal(final.content, '完成了')
270
+ void server
271
+ } finally {
272
+ server.close()
273
+ await done
274
+ }
275
+ })
276
+ })
277
+ test('S31: spawn CLI 全链——car run 真路径(--prompt/--base-url/--model + env 凭据)+ 无配置 exit 2', async () => {
278
+ await withTempDir('cli-real', async dir => {
279
+ const plugin = `export const manifest = { name: 'p', version: '1.0.0' }\n` +
280
+ `export default function apply(api) {\n` +
281
+ ` api.registerTool({ name: 'echo_tool', description: '回声工具', parameters: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] }, declaredSideEffect: 'readonly', run: async (a) => 'echo:' + a.text })\n` +
282
+ `}\n`
283
+ writeFileSync(join(dir, 'p.ts'), plugin)
284
+ const bodies: any[] = []
285
+ let n = 0
286
+ const server = createServer((req, res) => {
287
+ let raw = ''
288
+ req.on('data', c => { raw += c })
289
+ req.on('end', () => {
290
+ bodies.push(JSON.parse(raw))
291
+ res.writeHead(200, { 'content-type': 'text/event-stream' })
292
+ res.end(n++ === 0 ? TOOL_CALL_SSE('echo_tool', '{"text":"hi"}', 'call_1') : TEXT_SSE('完成了'))
293
+ })
294
+ })
295
+ await new Promise<void>(r => server.listen(0, '127.0.0.1', r))
296
+ const port = (server.address() as { port: number }).port
297
+ const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'run', 'p.ts',
298
+ '--prompt', 'call echo_tool with text hi', '--base-url', `http://127.0.0.1:${port}/v1`, '--model', 'cli-model',
299
+ '--system', '你是 CLI 集成测试助手'], {
300
+ cwd: dir,
301
+ env: { ...process.env, OPENAI_API_KEY: TEST_KEY, CAR_ALLOW_ENV_CREDENTIALS: '1' },
302
+ stdio: ['ignore', 'pipe', 'pipe'],
303
+ })
304
+ let out = ''
305
+ let err = ''
306
+ child.stdout.on('data', d => { out += d })
307
+ child.stderr.on('data', d => { err += d })
308
+ const code = await new Promise<number>(resolve => child.on('exit', c => resolve(c ?? -1)))
309
+ server.close()
310
+ assert.equal(code, 0, err)
311
+ assert.match(out, /turnEnd=completed/)
312
+ // system 出站(--system flag > 插件 setSystemPrompt)
313
+ assert.equal(bodies[0].messages[0].role, 'system')
314
+ assert.equal(bodies[0].messages[0].content, '你是 CLI 集成测试助手')
315
+ assert.equal(bodies[0].tools[0].function.name, 'echo_tool')
316
+ assert.match(out, /回放|投影/)
317
+
318
+ // 无模型配置 → exit 2 显式引导(D-13)
319
+ const noCfg = await new Promise<{ code: number; stderr: string }>(resolve => {
320
+ const c2 = spawn(process.execPath, ['--experimental-transform-types', CLI, 'run', 'p.ts'], { cwd: dir, stdio: ['ignore', 'pipe', 'pipe'] })
321
+ let e2 = ''
322
+ c2.stderr.on('data', d => { e2 += d })
323
+ c2.on('exit', c => resolve({ code: c ?? -1, stderr: e2 }))
324
+ })
325
+ assert.equal(noCfg.code, 2)
326
+ assert.match(noCfg.stderr, /CAR-E-LLM-CONFIG/)
327
+ assert.match(noCfg.stderr, /--demo/, '引导至演示流')
328
+ })
329
+ })
@@ -0,0 +1,218 @@
1
+ /**
2
+ * 1.4-S6~S7 · 遥测 trace 面 + MCP 工具进 turn + PTC 进真实 turn(S32)
3
+ *
4
+ * 覆盖(1.4-迭代规划 OTel 块 / MCP 块 / PTC 块):
5
+ * - telemetry trace 透传:traceId 复用 + parentSpanId 挂链(OTLP 出站体断言);缺省行为不变
6
+ * - TurnTracer:turn(根)→ step(子)层级;noop 门面零出站
7
+ * - 生产调用点:car run spawn 全链——CAR_OTEL_ENDPOINT 捕获 OTLP traces(共享 traceId + 父子链);
8
+ * --mcp(config mcp.servers + 受控 stdio 假 server)→ MCP 工具进声明面 + callTool 真派发;
9
+ * --ptc → run_code 参数 schema 出站 + worker 桥真调工具 + renderSdk 声明块进 system
10
+ */
11
+ import { test } from 'node:test'
12
+ import assert from 'node:assert/strict'
13
+ import { spawn } from 'node:child_process'
14
+ import { createServer, type Server } from 'node:http'
15
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
16
+ import { tmpdir } from 'node:os'
17
+ import { join, dirname } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+ import { createTelemetryFacade, type TelemetrySpan } from '../src/runtime-core/telemetry.ts'
20
+ import { TurnTracer } from '../src/runtime-core/trace.ts'
21
+
22
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
23
+ const CLI = join(ROOT, 'src', 'cli.ts')
24
+
25
+ const okRes = () => ({ ok: true, status: 200 }) as unknown as Response
26
+
27
+ // 密钥扫描口径:夹具凭据经变量注入(赋值行无引号字面量)
28
+ const TEST_KEY = 'test-key-123'
29
+
30
+ function withTempDir(name: string, fn: (dir: string) => Promise<void> | void): Promise<void> {
31
+ const dir = mkdtempSync(join(tmpdir(), `car-s32-${name}-`))
32
+ try {
33
+ const r = fn(dir)
34
+ const cleanup = () => { try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ } }
35
+ if (r instanceof Promise) return r.finally(cleanup)
36
+ cleanup()
37
+ return Promise.resolve()
38
+ } catch (e) {
39
+ try { rmSync(dir, { recursive: true, force: true }) } catch { /* 红线 8 */ }
40
+ throw e
41
+ }
42
+ }
43
+
44
+ const sse = (chunks: object[]) => chunks.map(c => `data: ${JSON.stringify(c)}\n\n`).join('') + 'data: [DONE]\n\n'
45
+
46
+ // ==================== trace 透传 + TurnTracer(单元) ====================
47
+
48
+ test('S32: telemetry trace 透传——traceId 复用 + parentSpanId 进 OTLP 出站体;缺省行为不变', async () => {
49
+ await withTempDir('trace-unit', async dir => {
50
+ const bodies: any[] = []
51
+ const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
52
+ fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
53
+ })
54
+ // 子 span:显式 trace(traceId 复用 + 父 spanId)
55
+ const child = f.getTracer().startSpan('car.step', { attributes: { 'car.step': 1 }, trace: { traceId: 'a'.repeat(32), parentSpanId: 'b'.repeat(16) } })
56
+ assert.equal(child.traceId, 'a'.repeat(32), '显式 traceId 复用')
57
+ assert.equal(child.spanId.length, 16, 'spanId 生成')
58
+ child.end()
59
+ // 兄弟 span:同 traceId、无 parent
60
+ const sibling = f.getTracer().startSpan('car.step', { trace: { traceId: 'a'.repeat(32) } })
61
+ sibling.end()
62
+ // 独立 span:缺省随机 traceId(1.1 行为不变)
63
+ const standalone: TelemetrySpan = f.getTracer().startSpan('standalone')
64
+ assert.notEqual(standalone.traceId, 'a'.repeat(32))
65
+ standalone.end()
66
+ await f.flush()
67
+ const spans = bodies.flatMap(b => b.scopeSpans[0].spans)
68
+ const c = spans.find((s: any) => s.name === 'car.step' && s.parentSpanId === 'b'.repeat(16))
69
+ assert.ok(c, 'parentSpanId 进出站体')
70
+ assert.equal(c.traceId, 'a'.repeat(32))
71
+ const sib = spans.find((s: any) => s.name === 'car.step' && !s.parentSpanId)
72
+ assert.ok(sib, '同 traceId 无父的兄弟 span')
73
+ })
74
+ })
75
+
76
+ test('S32: TurnTracer——turn(根)→ step(子)层级共享 traceId;noop 门面零出站', async () => {
77
+ await withTempDir('tracer', async dir => {
78
+ const bodies: any[] = []
79
+ const f = createTelemetryFacade({ endpoint: 'https://otel' }, {
80
+ fetchImpl: (async (_u: string | URL, init?: RequestInit) => { bodies.push(JSON.parse(String(init?.body))); return okRes() }) as typeof fetch,
81
+ })
82
+ const tracer = new TurnTracer(f)
83
+ const turn = tracer.startTurn('T0')
84
+ const step = turn.stepSpan(1)
85
+ step.recordException(new Error('x'))
86
+ step.end('toolUse')
87
+ turn.end('completed')
88
+ await f.flush()
89
+ const spans = bodies.flatMap(b => b.scopeSpans[0].spans)
90
+ const turnSpan = spans.find((s: any) => s.name === 'car.turn')
91
+ const stepSpan = spans.find((s: any) => s.name === 'car.step')
92
+ assert.ok(turnSpan && stepSpan)
93
+ assert.equal(stepSpan.traceId, turnSpan.traceId, '同 turn 共享 traceId')
94
+ assert.equal(stepSpan.parentSpanId, turnSpan.spanId, 'step 父 = turn spanId')
95
+ assert.equal(turnSpan.attributes.find((a: any) => a.key === 'car.outcome').value.stringValue, 'completed')
96
+ // noop 门面:enabled=false → 句柄零出站(默认关三原则在 trace 面成立)
97
+ const noopTracer = new TurnTracer(createTelemetryFacade(null))
98
+ const noopTurn = noopTracer.startTurn('T0')
99
+ noopTurn.stepSpan(1).end('stop')
100
+ noopTurn.end('completed')
101
+ const nf = createTelemetryFacade({ endpoint: 'https://otel' }, { fetchImpl: (async () => { throw new Error('零出站') }) as typeof fetch })
102
+ void nf
103
+ })
104
+ })
105
+
106
+ // ==================== 生产调用点:car run 全链(MCP + PTC + trace 出站) ====================
107
+
108
+ /** 受控 stdio MCP server(node 脚本文本):tools/list + tools/call(upper) */
109
+ const FAKE_MCP_SERVER = `
110
+ const lines = [];
111
+ let buf = '';
112
+ process.stdin.on('data', d => {
113
+ buf += d.toString();
114
+ let nl;
115
+ while ((nl = buf.indexOf('\\n')) >= 0) {
116
+ const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1);
117
+ if (!line) continue;
118
+ const req = JSON.parse(line);
119
+ if (req.method === 'tools/list') {
120
+ reply(req, { tools: [{ name: 'upper', description: '大写工具', sideEffect: 'readonly', inputSchema: { type: 'object', properties: { text: { type: 'string' } }, required: ['text'] } }] });
121
+ } else if (req.method === 'tools/call') {
122
+ reply(req, { content: [{ type: 'text', text: String(req.params.arguments.text).toUpperCase() }] });
123
+ }
124
+ }
125
+ });
126
+ function reply(req, result) { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\\n'); }
127
+ `
128
+
129
+ const RUN_CODE = `const r = await tools.mcp_helper_upper({ text: 'abc' })\nreturn r`
130
+
131
+ test('S32: car run 全链——MCP 工具进声明面+callTool 派发 / run_code worker 桥 / SDK 块进 system / trace 出站', async () => {
132
+ await withTempDir('full-chain', async dir => {
133
+ // 插件(声明面带 schema)
134
+ writeFileSync(join(dir, 'p.ts'), `export const manifest = { name: 'p', version: '1.0.0' }\n` +
135
+ `export default function apply(api) {\n` +
136
+ ` api.registerTool({ name: 'echo_tool', description: '回声', parameters: { type: 'object', properties: { text: { type: 'string' } } }, declaredSideEffect: 'readonly', run: async (a) => 'echo:' + a.text })\n` +
137
+ ` api.setSystemPrompt('插件级系统提示')\n` +
138
+ `}\n`)
139
+ // 配置:llm + mcp.servers
140
+ writeFileSync(join(dir, 'car.config.json'), JSON.stringify({
141
+ llm: { model: 'test-model' },
142
+ mcp: { servers: { helper: { command: process.execPath, args: ['-e', FAKE_MCP_SERVER] } } },
143
+ }))
144
+ // 假 LLM:req1 → run_code(worker 桥调 MCP 工具);req2 → done
145
+ const llmBodies: any[] = []
146
+ let n = 0
147
+ const llmServer: Server = createServer((req, res) => {
148
+ let raw = ''
149
+ req.on('data', c => { raw += c })
150
+ req.on('end', () => {
151
+ llmBodies.push(JSON.parse(raw))
152
+ res.writeHead(200, { 'content-type': 'text/event-stream' })
153
+ res.end(n++ === 0
154
+ ? sse([
155
+ { choices: [{ delta: { tool_calls: [{ index: 0, id: 'c1', function: { name: 'run_code', arguments: JSON.stringify({ code: RUN_CODE, description: '经桥调 MCP 大写' } ) } }] } }] },
156
+ { choices: [{ delta: {}, finish_reason: 'tool_calls' }] },
157
+ ])
158
+ : sse([{ choices: [{ delta: { role: 'assistant', content: '' } }] }, { choices: [{ delta: { content: 'done' } }] }, { choices: [{ delta: {}, finish_reason: 'stop' }] }]))
159
+ })
160
+ })
161
+ await new Promise<void>(r => llmServer.listen(0, '127.0.0.1', r))
162
+ const llmPort = (llmServer.address() as { port: number }).port
163
+ // OTLP 捕获端点
164
+ const otlpBodies: any[] = []
165
+ const otlp: Server = createServer((req, res) => {
166
+ let raw = ''
167
+ req.on('data', c => { raw += c })
168
+ req.on('end', () => { if (req.url === '/v1/traces') otlpBodies.push(JSON.parse(raw)); res.writeHead(200).end() })
169
+ })
170
+ await new Promise<void>(r => otlp.listen(0, '127.0.0.1', r))
171
+ const otlpPort = (otlp.address() as { port: number }).port
172
+
173
+ const child = spawn(process.execPath, ['--experimental-transform-types', CLI, 'run', 'p.ts',
174
+ '--prompt', 'uppercase abc', '--base-url', `http://127.0.0.1:${llmPort}/v1`,
175
+ '--mcp', 'helper', '--ptc'], {
176
+ cwd: dir,
177
+ env: { ...process.env, OPENAI_API_KEY: TEST_KEY, CAR_ALLOW_ENV_CREDENTIALS: '1', CAR_OTEL_ENDPOINT: `http://127.0.0.1:${otlpPort}` },
178
+ stdio: ['ignore', 'pipe', 'pipe'],
179
+ })
180
+ let out = ''
181
+ let err = ''
182
+ child.stdout.on('data', d => { out += d })
183
+ child.stderr.on('data', d => { err += d })
184
+ const exitDone = new Promise<number>(resolve => child.on('exit', c => resolve(c ?? -1)))
185
+ const code = await Promise.race([exitDone, new Promise<number>(r => setTimeout(() => r(-999), 60_000))])
186
+ llmServer.close()
187
+ otlp.close()
188
+
189
+ assert.equal(code, 0, `exit=${code}\nSTDOUT:\n${out}\nSTDERR:\n${err}`)
190
+ assert.match(out, /turnEnd=completed/)
191
+ // MCP:进声明面 + callTool 真派发
192
+ assert.match(out, /\[MCP\] helper: 1 工具入声明面/)
193
+ const req1Tools = llmBodies[0].tools.map((t: any) => t.function.name)
194
+ assert.ok(req1Tools.includes('mcp_helper_upper'), 'MCP 工具模型侧命名(D-18)')
195
+ assert.ok(req1Tools.includes('run_code'), 'run_code 入工具面')
196
+ const runCodeFn = llmBodies[0].tools.find((t: any) => t.function.name === 'run_code')
197
+ assert.deepEqual(runCodeFn.function.parameters.required, ['code', 'description'], 'run_code 参数 schema 出站(1.4 新增)')
198
+ // PTC:工具结果回链——写类工具先落授权决策 toolResult(M4 既有语义,观察项登记:投影双 tool 消息)、
199
+ // 再落执行结果;取最后一条(run_code 执行结果,worker 桥调 MCP upper → 'ABC')
200
+ const toolMsg = llmBodies[1].messages.filter((m: any) => m.role === 'tool').at(-1)
201
+ assert.ok(toolMsg, '第二请求含 run_code 结果')
202
+ assert.equal(JSON.stringify(toolMsg.content).includes('ABC'), true, `worker 桥真调 MCP 工具(结果应含 ABC):${JSON.stringify(toolMsg.content).slice(0, 200)}`)
203
+ // system:flag 缺省 → 插件 setSystemPrompt + SDK 声明块独立追加
204
+ assert.equal(llmBodies[0].messages[0].role, 'system')
205
+ assert.equal(llmBodies[0].messages[0].content, '插件级系统提示')
206
+ assert.equal(llmBodies[0].messages[1].role, 'system')
207
+ assert.match(llmBodies[0].messages[1].content, /declare const tools:/, 'renderSdk 声明块出站(1.4 前零生产调用点)')
208
+ assert.match(llmBodies[0].messages[1].content, /mcp_helper_upper/, 'SDK 声明含 MCP 工具')
209
+ // trace:turn/step 共享 traceId + 父子链(OTLP 出站)
210
+ await new Promise(r => setTimeout(r, 300))
211
+ const spans = otlpBodies.flatMap(b => b.scopeSpans[0].spans)
212
+ const turnSpan = spans.find((s: any) => s.name === 'car.turn')
213
+ const stepSpan = spans.find((s: any) => s.name === 'car.step')
214
+ assert.ok(turnSpan && stepSpan, `trace 出站(${otlpBodies.length} bodies, ${spans.length} spans)`)
215
+ assert.equal(stepSpan.traceId, turnSpan.traceId, '同 turn 共享 traceId')
216
+ assert.equal(stepSpan.parentSpanId, turnSpan.spanId, '父子链')
217
+ })
218
+ })