@lqc123qwe/car-runtime 1.3.0 → 1.5.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.
@@ -4,7 +4,8 @@
4
4
  * 口径(§3.2.M8.5 关键约束,逐条落地):
5
5
  * - 默认关:无端点配置时不初始化任何 exporter——无配置 = noop 句柄 + 零出站(可机器断言);
6
6
  * - 显式开:用户配置端点即视为开启(CAR_OTEL_ENDPOINT 或显式 config 传入);
7
- * - 端点归用户:数据只发往用户自配 E-05;OTLP/HTTP JSON(fetch),导出超时 5s;
7
+ * - 端点归用户:数据只发往用户自配 E-05;OTLP/HTTP JSON(node:http(s) 出站——1.4-BUG-2:全局
8
+ * fetch/undici 在 win32 进程退出时触发 libuv 断言崩溃),导出超时 5s;
8
9
  * 1.2-S3 生产级策略(D-12b 口径修订,2026-09-27):**0 重试 → 有界重试 ≤2**(仅 429/5xx/
9
10
  * 网络错误,退避 1s/2s;4xx 业务错不重试)——重试耗尽静默丢弃 + droppedExports 计数,
10
11
  * 永不抛错(BD-05「不抛错、不落审计」收紧保持);隐私三原则不变(数据仍只发用户自配端点);
@@ -22,6 +23,8 @@
22
23
  * 不引入 @opentelemetry/* 依赖(项目零依赖红线,README engines 纪律)。
23
24
  */
24
25
  import { randomBytes } from 'node:crypto'
26
+ import { request as httpRequest } from 'node:http'
27
+ import { request as httpsRequest } from 'node:https'
25
28
 
26
29
  export type SamplingSpec = 'always_on' | 'always_off' | { ratio: number }
27
30
 
@@ -55,10 +58,16 @@ export interface TelemetrySpan {
55
58
  recordException(err: unknown): this
56
59
  end(): void
57
60
  readonly traceId: string
61
+ /** 1.4-S6:子 span 挂链需要父 spanId(TurnTracer turn→step 层级) */
62
+ readonly spanId: string
58
63
  }
59
64
 
60
65
  export interface TelemetryTracer {
61
- startSpan(name: string, opts?: { attributes?: Record<string, string | number | boolean> }): TelemetrySpan
66
+ startSpan(name: string, opts?: {
67
+ attributes?: Record<string, string | number | boolean>
68
+ /** 1.4-S6:trace 透传(traceId 复用 + parentSpanId 挂链);缺省 = 新独立 trace */
69
+ trace?: { traceId: string; parentSpanId?: string }
70
+ }): TelemetrySpan
62
71
  }
63
72
 
64
73
  export interface TelemetryCounter {
@@ -102,6 +111,7 @@ const noopSpan = (): TelemetrySpan => ({
102
111
  recordException() { return noopSpan() },
103
112
  end() {},
104
113
  traceId: '',
114
+ spanId: '',
105
115
  })
106
116
  const NOOP_TRACER: TelemetryTracer = { startSpan: () => noopSpan() }
107
117
  const NOOP_METER: TelemetryMeter = { createCounter: () => ({ add() {} }) }
@@ -164,7 +174,25 @@ export function createTelemetryFacade(
164
174
  }
165
175
  }
166
176
 
167
- const fetchImpl = opts.fetchImpl ?? fetch
177
+ // 1.4-BUG-2:出站默认走 node:http(s)(非全局 fetch/undici)——win32 上 undici socket +
178
+ // 进程退出触发 libuv 断言崩溃(UV_HANDLE_CLOSING, 0xC0000409,Node 23.5 实测、22 不复现);
179
+ // 测试仍可注入 fetchImpl(缝保留)。res.resume() 排空响应体(连接干净归还)。
180
+ const fetchImpl = opts.fetchImpl ?? ((url: string | URL, init?: RequestInit): Promise<{ ok: boolean; status: number }> => new Promise(resolve => {
181
+ try {
182
+ const u = String(url)
183
+ const mod = u.startsWith('https:') ? httpsRequest : httpRequest
184
+ const req = mod(u, { method: init?.method ?? 'GET', headers: init?.headers as Record<string, string> | undefined }, res => {
185
+ res.resume()
186
+ res.on('end', () => {
187
+ const st = res.statusCode ?? 0
188
+ resolve({ ok: st >= 200 && st < 300, status: st })
189
+ })
190
+ })
191
+ req.setTimeout(timeoutMs, () => req.destroy(new Error('timeout')))
192
+ req.on('error', () => resolve({ ok: false, status: 599 })) // 网络/超时错误 → 599 可重试类
193
+ req.end(init?.body ?? null)
194
+ } catch { resolve({ ok: false, status: 599 }) }
195
+ }))
168
196
  const now = opts.now ?? Date.now
169
197
  const sleep = config?.sleep ?? (ms => new Promise<void>(r => setTimeout(r, ms)))
170
198
  const timeoutMs = config?.exportTimeoutMs ?? 5_000
@@ -194,12 +222,16 @@ export function createTelemetryFacade(
194
222
  */
195
223
  const exportOtlp = async (path: string, body: unknown): Promise<boolean> => {
196
224
  for (let attempt = 0; ; attempt++) {
225
+ // 1.4-BUG-2:AbortSignal.timeout 的内部定时器在进程退出时触发 libuv win32 断言崩溃
226
+ // (UV_HANDLE_CLOSING, 0xC0000409)——改手动 AbortController + clearTimeout 确定性清理
227
+ const controller = new AbortController()
228
+ const abortTimer = setTimeout(() => controller.abort(), timeoutMs)
197
229
  try {
198
230
  const res = await fetchImpl(endpoint + path, {
199
231
  method: 'POST',
200
232
  headers: baseHeaders,
201
233
  body: JSON.stringify(body),
202
- signal: AbortSignal.timeout(timeoutMs),
234
+ signal: controller.signal,
203
235
  })
204
236
  if (res.ok) return true
205
237
  if ((res.status === 429 || res.status >= 500) && attempt < retryLimit) {
@@ -214,6 +246,8 @@ export function createTelemetryFacade(
214
246
  continue
215
247
  }
216
248
  return false
249
+ } finally {
250
+ clearTimeout(abortTimer)
217
251
  }
218
252
  }
219
253
  }
@@ -277,9 +311,10 @@ export function createTelemetryFacade(
277
311
  const activeTracer: TelemetryTracer = {
278
312
  startSpan(name, startOpts) {
279
313
  const record: SpanRecord = {
280
- traceId: randomBytes(16).toString('hex'),
314
+ // 1.4-S6:trace 透传(traceId 复用 + parentSpanId 挂链)——缺省仍为独立随机 trace(1.1 行为不变)
315
+ traceId: startOpts?.trace?.traceId ?? randomBytes(16).toString('hex'),
281
316
  spanId: randomBytes(8).toString('hex'),
282
- parentSpanId: null,
317
+ parentSpanId: startOpts?.trace?.parentSpanId ?? null,
283
318
  name,
284
319
  startMs: now(),
285
320
  endMs: 0,
@@ -287,6 +322,7 @@ export function createTelemetryFacade(
287
322
  }
288
323
  return {
289
324
  get traceId() { return record.traceId },
325
+ get spanId() { return record.spanId },
290
326
  setAttribute(key, value) { record.attributes[key] = value; return this },
291
327
  addEvent(evtName, attrs) { record.attributes[`event.${evtName}`] = attrs ? JSON.stringify(attrs) : evtName; return this },
292
328
  recordException(err) { record.attributes['exception.message'] = String((err as Error)?.message ?? err); return this },
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 1.4-S1 · 工具声明面转换(执行面 Map → 模型声明面 ToolDefinition[])
3
+ *
4
+ * 口径(1.4 规划 W1-3 / D-18 / T-22 / 声明面=授权面红线):
5
+ * - **执行面与声明面同一对象**:ToolDef(loop/stop.ts,declaredSideEffect+run)即声明来源——
6
+ * 插件注册的 ToolReg 经 bindCore 收集后直接进本转换器,模型看到的工具集合 = 授权门放行的集合
7
+ * (多渲一个 = 无授权执行面,少渲一个 = 授权白给——PTC SDK 同款红线);
8
+ * - T-22 收口:declaredSideEffect 缺省 write(未声明按最高约束——mcp/gateway.ts 注册点同款);
9
+ * - 跨源同名显式报错(禁静默覆盖):Map 键即名字,插件/MCP 工具合并由调用方先行去重——
10
+ * 本转换器对重名(Map 键重复不可能,但 ToolDef.name 与键不一致时)显式报错;
11
+ * - declaredSideEffect 不出站(llm.ts 出站映射只带 name/description/parameters——权限面只进权限门)。
12
+ */
13
+ import type { ToolDef } from '../loop/stop.ts'
14
+ import type { ToolDefinition } from './types.ts'
15
+
16
+ export function toToolDefinitions(tools: Map<string, ToolDef>): ToolDefinition[] {
17
+ const out: ToolDefinition[] = []
18
+ for (const [name, t] of tools) {
19
+ out.push({
20
+ name,
21
+ ...(t.description !== undefined ? { description: t.description } : {}),
22
+ ...(t.parameters !== undefined ? { parameters: t.parameters } : {}),
23
+ declaredSideEffect: t.declaredSideEffect ?? 'write',
24
+ })
25
+ }
26
+ return out
27
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * 1.4-S6 · Turn 级 trace 面(W1 遥测未入链收尾):turn span 包全程、step span 挂每次 model() 调用
3
+ *
4
+ * 口径(1.4 规划 W1 遥测块 / telemetry.ts 头注释):
5
+ * - 层级:turn(根 span,traceId 在此生成)→ step(parentSpanId = turn.spanId)——同一 turn 共享 trace;
6
+ * - **挂点在装配层**(car run 的 model 回调闭包),loop 层零改动——遥测治理属调用方(telemetry.ts
7
+ * 「内容治理属调用方」口径);设计微调登记:规划原文「runTurn opts.telemetry 注入面」改为装配层
8
+ * 闭包挂点,语义等价(turn/step 层级与出站面不变),loop 层保持纯停止语义;
9
+ * - attributes 零内容面:turn_id/step 序号/outcome 枚举——无会话内容/提示词/路径。
10
+ */
11
+ import { randomBytes } from 'node:crypto'
12
+ import type { TelemetryFacade } from './telemetry.ts'
13
+
14
+ export interface StepSpanHandle {
15
+ end(outcome: string): void
16
+ recordException(e: unknown): void
17
+ }
18
+
19
+ export interface TurnSpanHandle {
20
+ traceId: string
21
+ spanId: string
22
+ end(outcome: string): void
23
+ stepSpan(step: number): StepSpanHandle
24
+ }
25
+
26
+ export class TurnTracer {
27
+ readonly #facade: TelemetryFacade | null
28
+
29
+ constructor(facade: TelemetryFacade | null | undefined) {
30
+ // 默认关(无端点)= noop 句柄(零出站零积累——telemetry 三原则)
31
+ this.#facade = facade?.enabled ? facade : null
32
+ }
33
+
34
+ startTurn(turnId: string): TurnSpanHandle {
35
+ if (!this.#facade) {
36
+ return { traceId: '', spanId: '', end() {}, stepSpan: () => ({ end() {}, recordException() {} }) }
37
+ }
38
+ const tracer = this.#facade.getTracer()
39
+ const traceId = randomBytes(16).toString('hex')
40
+ const turn = tracer.startSpan('car.turn', { attributes: { 'car.turn_id': turnId }, trace: { traceId } })
41
+ return {
42
+ traceId,
43
+ spanId: turn.spanId,
44
+ end(outcome) {
45
+ turn.setAttribute('car.outcome', outcome)
46
+ turn.end()
47
+ },
48
+ stepSpan(step: number): StepSpanHandle {
49
+ const s = tracer.startSpan('car.step', {
50
+ attributes: { 'car.turn_id': turnId, 'car.step': step },
51
+ trace: { traceId, parentSpanId: turn.spanId },
52
+ })
53
+ return {
54
+ end(outcome) {
55
+ s.setAttribute('car.outcome', outcome)
56
+ s.end()
57
+ },
58
+ recordException(e) { s.recordException(e) },
59
+ }
60
+ },
61
+ }
62
+ }
63
+ }
@@ -19,9 +19,12 @@ export interface LlmRequest {
19
19
  tools: ToolDefinition[]
20
20
  maxTokens?: number
21
21
  metadata?: { sessionId: string; turnId: string; traceId: string }
22
- // —— 规格外加法扩展(登记:M8 收口文档 §4)——
22
+ // —— 规格外加法扩展(登记:M8 收口文档 §4 / 1.4-S4)——
23
23
  /** 多适配器在册时显式路由;缺省用默认适配器 */
24
24
  adapterId?: string
25
+ /** 1.4-S4(D-19):turn 取消信号(runTurn 的 {aborted} 形态透传)——适配器在 attempt 前 /
26
+ * chunk 间隙检查;外部取消 ≠ 失败(不 retry、不产 error chunk,静默收口由 chatStep 抛 TurnAborted) */
27
+ signal?: { aborted: boolean }
25
28
  }
26
29
 
27
30
  /** 工具调用流式增量(跨 chunk 聚合出完整 ToolCall) */
@@ -10,7 +10,7 @@ import { createHash } from 'node:crypto'
10
10
  import { readFileSync } from 'node:fs'
11
11
  import { decodeLogBuffer, splitJsonlLines } from './format.ts'
12
12
 
13
- export type EventKind = 'user' | 'assistant' | 'toolCall' | 'toolResult' | 'turnEnd' | 'goalUpdate' | 'hostRaw' | 'fork' // M3-S11 加法扩展:多宿主归一化降级通道(pattern 未命中留痕,零静默);M5-DEC4 加法扩展:跨宿主 fork 标记事件;六值 TurnEndReason 不变
13
+ export type EventKind = 'user' | 'assistant' | 'toolCall' | 'toolResult' | 'turnEnd' | 'goalUpdate' | 'hostRaw' | 'fork' | 'system' // M3-S11 加法扩展:多宿主归一化降级通道(pattern 未命中留痕,零静默);M5-DEC4 加法扩展:跨宿主 fork 标记事件;1.4-S2 加法扩展:system prompt 落链(§3.5.5:新模型可见输入 = 新事件类型);六值 TurnEndReason 不变
14
14
  export type Actor = 'user' | 'model' | 'plugin' | 'runtime'
15
15
  export interface SessionEvent {
16
16
  seq: number
@@ -88,7 +88,8 @@ export class SessionLog {
88
88
  const out: Array<{ role: string; [k: string]: unknown }> = []
89
89
  for (const e of this.events) {
90
90
  if (upTo !== undefined && e.seq >= upTo) break
91
- if (e.kind === 'user') out.push({ role: 'user', content: e.payload })
91
+ if (e.kind === 'system') out.push({ role: 'system', content: e.payload })
92
+ else if (e.kind === 'user') out.push({ role: 'user', content: e.payload })
92
93
  else if (e.kind === 'assistant') out.push({ role: 'assistant', content: e.payload })
93
94
  else if (e.kind === 'toolCall') out.push({ role: 'assistant', toolCall: e.payload })
94
95
  else if (e.kind === 'toolResult') out.push({ role: 'toolResult', content: e.payload })
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "comment": "J-09 contract 基线:SessionEventMap/SessionEvent 签名字段集。只增不删——本文件中任何键从 src/session/log.ts 的当前签名中消失即 FAIL(M2部署设计增补 §2.3 J-09)",
3
- "eventKinds": ["user", "assistant", "toolCall", "toolResult", "turnEnd", "goalUpdate", "hostRaw", "fork"],
3
+ "eventKinds": ["user", "assistant", "toolCall", "toolResult", "turnEnd", "goalUpdate", "hostRaw", "fork", "system"],
4
4
  "actors": ["user", "model", "plugin", "runtime"],
5
5
  "sessionEventFields": ["seq", "ts", "actor", "kind", "turnId", "payload", "meta", "prevHash", "hash"],
6
6
  "sessionLogApi": ["append", "snapshotModelRequest", "verifyChain", "deriveMessages", "assertModelVisibleLogged", "stats", "fromEvents", "exportJSONL"]
@@ -273,7 +273,7 @@ test('S26: CLI 配置通道真实消费——car run 经 car.config.json 走 enf
273
273
  assert.equal(r1.code, 1)
274
274
  assert.match(r1.stderr, /签名门禁拒绝.*signature missing/)
275
275
  // env 显式 0 压过配置 → warn 放行到装配段
276
- const r2 = await car(['run', 'plugin.ts'], { cwd: dir, env: { CAR_SIG_ENFORCE: '0' } })
276
+ const r2 = await car(['run', 'plugin.ts', '--demo'], { cwd: dir, env: { CAR_SIG_ENFORCE: '0' } }) // 1.4 D-13:stub 流挂 --demo
277
277
  assert.equal(r2.code, 0, r2.stderr)
278
278
  assert.match(r2.stdout, /\[1\/5 装配\] OK/)
279
279
  // 坏配置 fail-visible 中止(exit 2,不进入装载)
@@ -213,7 +213,7 @@ test('S28: 生产调用点——car run 真进程 append → cwd sessions-index.
213
213
  // 工具名避开 demo_tool:CLI 演示脚手架硬编码注册 demo_tool,同名插件工具触发预存在的 provide
214
214
  // 撞名边缘(登记不修——W1 重做 car run 装配面时出清)
215
215
  writeFileSync(join(dir, 'p.ts'), `export const manifest = { name: 'p', version: '1.0.0' }\nexport default function apply(api) {\n api.registerTool({ name: 't', run: async () => 'ok' })\n}\n`)
216
- const r = await car(['run', 'p.ts'], { cwd: dir })
216
+ const r = await car(['run', 'p.ts', '--demo'], { cwd: dir }) // 1.4 D-13:stub 流挂 --demo
217
217
  assert.equal(r.code, 0, r.stderr)
218
218
  const dbPath = join(dir, 'sessions-index.db')
219
219
  assert.ok(existsSync(dbPath), 'car run 运行后 cwd 索引自举在位')
@@ -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
+ })