Ctrl K
ComunidadGuías…

src/core/contracts/tools-idempotency.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 工具幂等账本单测:同 job 内"副作用工具"相同调用只真正执行一次(防 job 重试/崩溃恢复重复副作用)。
// 零依赖:stub idempotency 用内存 Map、stub audit、mock 全局 fetch 计数,不连 mysql/不发真实请求。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { TOOL_DEFINITION_SCHEMA_VERSION, type ToolDefinition } from './tool-definition';
import { argsHash, AUDIT_MAX_BYTES, buildToolRuntime, toolCallIdempotencyKey, type ToolExecutionJournalEntry, type ToolRuntimeDeps } from './tools';
import { TOOL_ARGUMENTS_MAX_BYTES } from './tool-input-limits';

function sourceArgsWithBytes(bytes: number): Record<string, unknown> {
  const prefix = 'synthetic-中😀"\\';
  const args = { source: { html: prefix, css: '' } };
  const used = Buffer.byteLength(JSON.stringify(args), 'utf8');
  assert.ok(bytes >= used);
  args.source.html += 'x'.repeat(bytes - used);
  assert.equal(Buffer.byteLength(JSON.stringify(args), 'utf8'), bytes);
  return args;
}

function sourceTool(over: Partial<ToolDefinition> = {}): ToolDefinition {
  return mkTool({ inputSchema: { type: 'object', properties: { source: { type: 'object', properties: {
    html: { type: 'string' }, css: { type: 'string' },
  }, required: ['html', 'css'], additionalProperties: false } }, required: ['source'] }, ...over });
}

test('runtime transport preserves JSON source and signatures through the inclusive 1 MiB budget', async () => {
  const originalFetch = globalThis.fetch;
  const sent: string[] = [];
  globalThis.fetch = async (_url, init) => {
    const body = String(init?.body);
    const headers = new Headers(init?.headers);
    const ts = Number(headers.get('x-bailing-timestamp'));
    const { signToolCall } = await import('./tools');
    assert.equal(headers.get('x-bailing-signature'), signToolCall('s', ts, 'POST', '/create', body, 'u1', 'job1'));
    sent.push(body);
    return new Response('{"ok":true}', { status: 200 });
  };
  try {
    for (const bytes of [4395, 5183, 16 * 1024, 64 * 1024, 256 * 1024, TOOL_ARGUMENTS_MAX_BYTES]) {
      const args = sourceArgsWithBytes(bytes);
      const out = await mkRuntime(sourceTool({ readonly: true })).invoke('create_thing', args);
      assert.equal(out.ok, true);
      assert.equal(sent.at(-1), JSON.stringify(args));
      assert.deepEqual(JSON.parse(sent.at(-1)!), args);
    }
    assert.equal(sent.length, 6);
  } finally { globalThis.fetch = originalFetch; }
});

test('runtime over-limit rejection precedes every approval, rate and dispatch side effect', async () => {
  const audits: Array<Record<string, unknown>> = [];
  const forbidden = async (): Promise<never> => { throw new Error('must not reach a governed side effect'); };
  const originalFetch = globalThis.fetch; globalThis.fetch = forbidden;
  try {
    const runtime = mkRuntime(sourceTool({ risk: 'high' }), async (_event, detail) => { audits.push(detail); }, undefined, {
      rateLimitAll: forbidden, beforeDispatch: forbidden,
      approvals: { consumeApproved: forbidden, findPending: forbidden, findApprovedAnyArgs: forbidden, create: forbidden, notify: forbidden },
      idempotency: { get: forbidden, reserve: forbidden, recordResponse: forbidden, complete: forbidden, markUncertain: forbidden, markEvidenceDegraded: forbidden },
    });
    const args = sourceArgsWithBytes(TOOL_ARGUMENTS_MAX_BYTES + 1);
    const out = await runtime.invoke('create_thing', args);
    assert.equal(out.error, 'arguments_too_large'); assert.equal(out.status, 413);
    assert.equal(out.dispatch, 'not_dispatched'); assert.equal(out.origin, 'core');
    assert.equal(out.governance_state, 'rejected_before_dispatch'); assert.equal(out.auto_retry_allowed, false);
    assert.equal(out.actual_bytes, TOOL_ARGUMENTS_MAX_BYTES + 1); assert.equal(out.max_bytes, TOOL_ARGUMENTS_MAX_BYTES);
    assert.equal(out.limit_scope, 'arguments');
    assert.equal(audits.length, 1); assert.ok(JSON.stringify(audits).length < 300);
    assert.equal(JSON.stringify(out).includes('synthetic-'), false);
  } finally { globalThis.fetch = originalFetch; }
});

test('large approval keeps exact frozen arguments and resumes once while notification is only a labelled view', async () => {
  const args = sourceArgsWithBytes(TOOL_ARGUMENTS_MAX_BYTES);
  const encoded = JSON.stringify(args); const hash = argsHash(args);
  let approved = false, consumed = 0, calls = 0; const snapshots: any[] = [], notices: any[] = [];
  const runtime = mkRuntime(sourceTool({ risk: 'high', confirmPrompt: '{source}' }), undefined, undefined, {
    approvals: {
      consumeApproved: async (_tool, seen) => { assert.equal(seen, hash); if (!approved) return null; consumed++; return 7; },
      findPending: async () => null, findApprovedAnyArgs: async () => null,
      create: async snap => { snapshots.push(snap); return 7; },
      notify: async (_id, snap) => { notices.push(snap); },
    },
  });
  const originalFetch = globalThis.fetch;
  globalThis.fetch = async (_url, init) => { calls++; assert.equal(String(init?.body), encoded); return new Response('{"ok":true}'); };
  try {
    assert.equal((await runtime.invoke('create_thing', args)).governance_state, 'awaiting_approval');
    assert.equal(calls, 0); assert.equal(snapshots[0].args_json, encoded); assert.equal(snapshots[0].args_hash, hash);
    assert.equal(notices[0].args_truncated, true); assert.equal(notices[0].args_bytes, TOOL_ARGUMENTS_MAX_BYTES);
    assert.equal(notices[0].args_hash, hash); assert.ok(Buffer.byteLength(notices[0].args_json, 'utf8') <= 1000);
    assert.equal(JSON.parse(notices[0].args_json).truncated, true);
    approved = true;
    assert.equal((await runtime.invoke('create_thing', JSON.parse(snapshots[0].args_json))).ok, true);
    assert.equal((await runtime.invoke('create_thing', args)).ok, true);
    assert.equal(calls, 1); assert.equal(consumed, 1);
  } finally { globalThis.fetch = originalFetch; }
});

function mkTool(over: Partial<ToolDefinition>): ToolDefinition {
  return {
    schemaVersion: TOOL_DEFINITION_SCHEMA_VERSION,
    name: 'create_thing', source: 'manual', method: 'POST', path: '/create', description: '建个东西', scope: 'thing.write',
    risk: 'low', confirmRequired: false, rateLimitPerMin: 0, requiresSubject: false, sensitive: false,
    readonly: false, idempotent: false, timeoutMs: 0, confirmPrompt: '',
    context: [], extensions: {},
    inputSchema: { type: 'object', properties: { x: { type: 'number' } } }, paramIn: {},
    ...over,
  };
}

function mkRuntime(
  tool: ToolDefinition,
  audit: ToolRuntimeDeps['audit'] = async () => { /* noop */ },
  ledger = new Map<string, ToolExecutionJournalEntry>(),
  overrides: Partial<ToolRuntimeDeps> = {},
) {
  const deps: ToolRuntimeDeps = {
    provider: { name: 'p', base_url: 'http://x.invalid', secret: 's', timeout_ms: 5000, rate_limit_per_min: 0, log_payload: false } as any,
    allowedTools: [tool], maxCalls: 10, onBehalfOf: 'u1', jobId: 'job1', clientAppId: 'c', truncateBytes: 8192,
    audit,
    idempotency: {
      get: async (t, h) => ledger.get(`${t}:${h}`) ?? null,
      reserve: async (t, _scope, h, idempotencyKey) => {
        const key = `${t}:${h}`;
        const prior = ledger.get(key);
        if (prior) return { inserted: false, entry: prior };
        const entry: ToolExecutionJournalEntry = { state: 'dispatching', ok: false, status: 0, text: '', idempotencyKey };
        ledger.set(key, entry);
        return { inserted: true, entry };
      },
      recordResponse: async (t, h, r) => {
        const key = `${t}:${h}`;
        const prior = ledger.get(key);
        assert.equal(prior?.state, 'dispatching');
        ledger.set(key, { ...prior!, ...r, state: 'response_recorded' });
      },
      complete: async (t, h) => {
        const key = `${t}:${h}`;
        const prior = ledger.get(key);
        assert.equal(prior?.state, 'response_recorded');
        ledger.set(key, { ...prior!, state: 'completed' });
      },
      markUncertain: async (t, h, error) => {
        const key = `${t}:${h}`;
        const prior = ledger.get(key)!;
        ledger.set(key, { ...prior, state: 'uncertain', error });
      },
      markEvidenceDegraded: async (t, h, error) => {
        const key = `${t}:${h}`;
        const prior = ledger.get(key)!;
        ledger.set(key, { ...prior, state: 'evidence_degraded', error });
      },
    },
  };
  return buildToolRuntime({ ...deps, ...overrides });
}

function mkRuntimeWithApprovals(tool: ToolDefinition) {
  let created = 0;
  let notified = 0;
  const ledger = new Map<string, ToolExecutionJournalEntry>();
  const createdSnaps: any[] = [];
  const notifiedSnaps: any[] = [];
  const audits: Array<{ event: string; detail: Record<string, unknown> }> = [];
  const deps: ToolRuntimeDeps = {
    provider: { name: 'p', base_url: 'http://x.invalid', secret: 's', timeout_ms: 5000, rate_limit_per_min: 0, log_payload: false } as any,
    allowedTools: [tool], maxCalls: 10, onBehalfOf: 'tenant_1:user_9', jobId: 'job1', clientAppId: 'c', truncateBytes: 8192,
    audit: async (event, detail) => { audits.push({ event, detail }); },
    approvals: {
      consumeApproved: async () => null,
      findPending: async () => null,
      findApprovedAnyArgs: async () => null,
      create: async (snap) => { createdSnaps.push(snap); return ++created; },
      notify: async (_id, snap) => { notifiedSnaps.push(snap); notified++; },
    },
    idempotency: {
      get: async (t, h) => ledger.get(`${t}:${h}`) ?? null,
      reserve: async (t, _scope, h, idempotencyKey) => {
        const key = `${t}:${h}`;
        const prior = ledger.get(key);
        if (prior) return { inserted: false, entry: prior };
        const entry: ToolExecutionJournalEntry = { state: 'dispatching', ok: false, status: 0, text: '', idempotencyKey };
        ledger.set(key, entry);
        return { inserted: true, entry };
      },
      recordResponse: async (t, h, r) => {
        const key = `${t}:${h}`;
        ledger.set(key, { ...ledger.get(key)!, ...r, state: 'response_recorded' });
      },
      complete: async (t, h) => {
        const key = `${t}:${h}`;
        ledger.set(key, { ...ledger.get(key)!, state: 'completed' });
      },
      markUncertain: async (t, h, error) => {
        const key = `${t}:${h}`;
        ledger.set(key, { ...ledger.get(key)!, state: 'uncertain', error });
      },
      markEvidenceDegraded: async (t, h, error) => {
        const key = `${t}:${h}`;
        ledger.set(key, { ...ledger.get(key)!, state: 'evidence_degraded', error });
      },
    },
  };
  return { runtime: buildToolRuntime(deps), count: () => ({ created, notified, createdSnaps, notifiedSnaps, audits }) };
}

async function withFetchCounter(fn: (count: () => number) => Promise<void>): Promise<void> {
  let n = 0;
  const orig = globalThis.fetch;
  globalThis.fetch = (async () => { n++; return new Response('{"ok":true}', { status: 200 }); }) as typeof fetch;
  try { await fn(() => n); } finally { globalThis.fetch = orig; }
}

test('副作用工具:同 job 同参数第二次调用被去重,只真正执行一次', async () => {
  const rt = mkRuntime(mkTool({}));
  await withFetchCounter(async (count) => {
    const r1 = await rt.invoke('create_thing', { x: 1 });
    assert.equal(r1.ok, true);
    assert.equal(count(), 1, '第一次应真正发出');
    const r2 = await rt.invoke('create_thing', { x: 1 });
    assert.equal(r2.ok, true, '第二次返回上次缓存结果');
    assert.equal(count(), 1, '相同调用不应再次发出(防重复副作用)');
    await rt.invoke('create_thing', { x: 2 });
    assert.equal(count(), 2, '参数不同是另一次调用,应真正发出');
  });
});

test('副作用工具:缺少持久化执行日志时 fail-closed,请求不得外发', async () => {
  const audits: Array<{ event: string; detail: Record<string, unknown> }> = [];
  const runtime = buildToolRuntime({
    provider: { name: 'p', base_url: 'http://x.invalid', secret: 's', timeout_ms: 5000, rate_limit_per_min: 0, log_payload: false } as any,
    allowedTools: [mkTool({})],
    maxCalls: 10,
    onBehalfOf: 'u1',
    jobId: 'job1',
    clientAppId: 'c',
    truncateBytes: 8192,
    audit: async (event, detail) => { audits.push({ event, detail }); },
  });

  await withFetchCounter(async (count) => {
    const result = await runtime.invoke('create_thing', { x: 1 });
    assert.equal(result.ok, false);
    assert.match(result.text, /持久化执行日志/);
    assert.equal(count(), 0);
  });
  assert.equal(audits.at(-1)?.event, 'tool_blocked');
});

test('副作用工具:执行日志查询不可用时先于审批 fail-closed,不消费批准也不外发', async () => {
  let approvalConsumes = 0;
  const audits: Array<{ event: string; detail: Record<string, unknown> }> = [];
  const runtime = buildToolRuntime({
    provider: { name: 'p', base_url: 'http://x.invalid', secret: 's', timeout_ms: 5000, rate_limit_per_min: 0, log_payload: false } as any,
    allowedTools: [mkTool({ name: 'refund_create', risk: 'high', confirmRequired: true })],
    maxCalls: 10,
    onBehalfOf: 'u1',
    jobId: 'job1',
    clientAppId: 'c',
    truncateBytes: 8192,
    audit: async (event, detail) => { audits.push({ event, detail }); },
    approvals: {
      consumeApproved: async () => { approvalConsumes++; return 42; },
      findPending: async () => null,
      findApprovedAnyArgs: async () => null,
      create: async () => 1,
      notify: async () => undefined,
    },
    idempotency: {
      get: async () => { throw new Error('journal unavailable'); },
      reserve: async () => { throw new Error('reserve must not run'); },
      recordResponse: async () => { throw new Error('recordResponse must not run'); },
      complete: async () => { throw new Error('complete must not run'); },
      markUncertain: async () => undefined,
      markEvidenceDegraded: async () => undefined,
    },
  });

  await withFetchCounter(async (count) => {
    const result = await runtime.invoke('refund_create', { x: 1 });
    assert.equal(result.ok, false);
    assert.match(result.text, /执行日志当前不可用/);
    assert.match(result.text, /未消费审批/);
    assert.equal(approvalConsumes, 0, '账本状态未知时不得提前消费批准');
    assert.equal(count(), 0, '账本状态未知时不得外发业务请求');
  });
  assert.equal(audits.at(-1)?.event, 'tool_blocked');
  assert.equal(audits.at(-1)?.detail['reason'], '执行日志查询不可用,请求未发出');
});

test('副作用工具:下游超时后冻结执行,恢复或重跑不得再次发出', async () => {
  const ledger = new Map<string, ToolExecutionJournalEntry>();
  const rt = mkRuntime(mkTool({}), undefined, ledger);
  const orig = globalThis.fetch;
  let calls = 0;
  globalThis.fetch = (async () => {
    calls++;
    throw new TypeError('connection reset after request');
  }) as typeof fetch;
  try {
    const first = await rt.invoke('create_thing', { x: 7 });
    assert.equal(first.ok, false);
    assert.match(first.text, /人工对账/);
    assert.equal(rt.executionUncertainty()?.state, 'uncertain');

    const recoveredRuntime = mkRuntime(mkTool({}), undefined, ledger);
    const second = await recoveredRuntime.invoke('create_thing', { x: 7 });
    assert.equal(second.ok, false);
    assert.match(second.text, /人工对账/);
    assert.equal(recoveredRuntime.executionUncertainty()?.state, 'uncertain');
    assert.equal(calls, 1, '进程恢复后也不得自动重放结果不确定的业务请求');
  } finally {
    globalThis.fetch = orig;
  }
});

test('工具响应传输:200/302/500 正文超限都取消并冻结,恢复不得重投写入', async (t) => {
  for (const status of [200, 302, 500]) {
    for (const declared of [false, true]) {
      await t.test(`status=${status}, declared=${declared}`, async (t) => {
        let calls = 0;
        let pulls = 0;
        let cancels = 0;
        const audits: Array<{ event: string; detail: Record<string, unknown> }> = [];
        t.mock.method(globalThis, 'fetch', async () => {
          calls++;
          return new Response(new ReadableStream<Uint8Array>({
            pull(controller) {
              pulls++;
              controller.enqueue(new Uint8Array(pulls === 1 ? 32 : 1));
            },
            cancel() { cancels++; },
          }, { highWaterMark: 0 }), { status, headers: declared ? { 'content-length': '33' } : {} });
        });
        const ledger = new Map<string, ToolExecutionJournalEntry>();
        const runtime = mkRuntime(mkTool({}), async (event, detail) => { audits.push({ event, detail }); }, ledger, { maxResponseBodyBytes: 32 });
        const first = await runtime.invoke('create_thing', { x: 1 });
        assert.equal(first.ok, false);
        assert.equal(first.status, 0, 'headers alone cannot confirm a write after body loss');
        assert.equal(first.governance_state, 'reconciliation_required');
        assert.equal(first.auto_retry_allowed, false);
        assert.equal(runtime.executionUncertainty()?.state, 'uncertain');
        assert.equal(cancels, 1);
        assert.equal(pulls, declared ? 0 : 2);
        assert.equal(audits.some((audit) => audit.event === 'tool_result'), false);
        const recovered = mkRuntime(mkTool({}), undefined, ledger, { maxResponseBodyBytes: 32 });
        assert.equal((await recovered.invoke('create_thing', { x: 1 })).ok, false);
        assert.equal(calls, 1);
      });
    }
  }
});

test('工具默认传输预算:超过固定5 MiB的声明也不能确认小正文写入', async (t) => {
  t.mock.method(globalThis, 'fetch', async () => new Response('synthetic', {
    status: 200, headers: { 'content-length': String(5 * 1024 * 1024 + 1) },
  }));
  const runtime = mkRuntime(mkTool({}));
  const result = await runtime.invoke('create_thing', {});
  assert.equal(result.ok, false);
  assert.equal(result.governance_state, 'reconciliation_required');
  assert.equal(result.auto_retry_allowed, false);
  assert.equal(runtime.executionUncertainty()?.state, 'uncertain');
});

test('工具响应传输:body timeout 保留原信号和 unknown 结果,不记录半截成功', async (t) => {
  let calls = 0;
  const audits: Array<{ event: string; detail: Record<string, unknown> }> = [];
  t.mock.method(globalThis, 'fetch', async (_url: unknown, init: RequestInit) => {
    calls++;
    return new Response(new ReadableStream<Uint8Array>({
      start(controller) {
        controller.enqueue(new TextEncoder().encode('{"ok":'));
        init.signal!.addEventListener('abort', () => controller.error(init.signal!.reason), { once: true });
      },
    }), { status: 200 });
  });
  const runtime = mkRuntime(mkTool({ timeoutMs: 20 }), async (event, detail) => { audits.push({ event, detail }); });
  const watchdog = setTimeout(() => undefined, 1000);
  try {
    const result = await runtime.invoke('create_thing', { x: 1 });
    assert.equal(result.ok, false);
    assert.equal(result.governance_state, 'reconciliation_required');
    assert.match(runtime.executionUncertainty()?.reason ?? '', /超时/);
    assert.equal(audits.some((audit) => audit.event === 'tool_result'), false);
    await runtime.invoke('create_thing', { x: 1 });
    assert.equal(calls, 1);
  } finally { clearTimeout(watchdog); }
});

test('工具响应视图:中文的模型和审计 UTF-8 字节预算真实有界', async (t) => {
  const audits: Array<{ event: string; detail: Record<string, unknown> }> = [];
  const value = '汉'.repeat(AUDIT_MAX_BYTES);
  t.mock.method(globalThis, 'fetch', async () => new Response(value));
  const def = mkTool({ inputSchema: { type: 'object', properties: { x: { type: 'string' } } } });
  const runtime = mkRuntime(def, async (event, detail) => { audits.push({ event, detail }); }, undefined,
    { provider: { name: 'p', base_url: 'https://synthetic.invalid', secret: 'synthetic', timeout_ms: 5000, rate_limit_per_min: 0, log_payload: true } as any });
  const result = await runtime.invoke('create_thing', { x: value });
  assert.equal(result.ok, true);
  assert.equal(Buffer.byteLength(result.text), 8190);
  assert.equal(result.text.includes('\ufffd'), false);
  const call = audits.find((entry) => entry.event === 'tool_call')!.detail;
  assert.equal(call.args_bytes, Buffer.byteLength(JSON.stringify({ x: value })));
  assert.ok(Buffer.byteLength(String(call.args)) <= AUDIT_MAX_BYTES);
  assert.equal(call.args_truncated, true);
  const response = audits.find((entry) => entry.event === 'tool_result')!.detail;
  assert.equal(response.resp_bytes, Buffer.byteLength(value));
  assert.ok(Buffer.byteLength(String(response.resp)) <= AUDIT_MAX_BYTES);
  assert.equal(response.resp_truncated, true);
});

test('工具响应视图:敏感工具仍脱敏,响应字节数保持实际输入计数', async (t) => {
  const audits: Array<{ event: string; detail: Record<string, unknown> }> = [];
  t.mock.method(globalThis, 'fetch', async () => new Response(new Uint8Array([255])));
  const runtime = mkRuntime(mkTool({ sensitive: true }), async (event, detail) => { audits.push({ event, detail }); }, undefined,
    { provider: { name: 'p', base_url: 'https://synthetic.invalid', secret: 'synthetic', timeout_ms: 5000, rate_limit_per_min: 0, log_payload: true } as any });
  await runtime.invoke('create_thing', { x: 42 });
  const response = audits.find((entry) => entry.event === 'tool_result')!.detail;
  assert.equal(response.resp_bytes, 1, 'replacement decoding must not alter consumed byte count');
  assert.equal(response.resp, '<1 bytes>');
  assert.equal(audits.find((entry) => entry.event === 'tool_call')!.detail.args, '["x"]');
});

test('工具响应视图:空响应 fallback 也遵守极小字节预算', async (t) => {
  t.mock.method(globalThis, 'fetch', async () => new Response(null, { status: 204 }));
  const runtime = mkRuntime(mkTool({}), undefined, undefined, { truncateBytes: 1 });
  const result = await runtime.invoke('create_thing', {});
  assert.equal(result.ok, true);
  assert.ok(Buffer.byteLength(result.text) <= 1);
});

test('副作用工具:崩溃遗留 dispatching 占位时,新运行时 fail-closed 且不外发', async () => {
  const callArgs = { x: 10 };
  const hash = argsHash(callArgs);
  const idempotencyKey = toolCallIdempotencyKey('job1', 'create_thing', hash, 'u1');
  const ledger = new Map<string, ToolExecutionJournalEntry>([
    [`create_thing:${hash}`, {
      state: 'dispatching',
      ok: false,
      status: 0,
      text: '',
      idempotencyKey,
    }],
  ]);
  const recoveredRuntime = mkRuntime(mkTool({}), undefined, ledger);

  await withFetchCounter(async (count) => {
    const result = await recoveredRuntime.invoke('create_thing', callArgs);
    assert.equal(result.ok, false);
    assert.match(result.text, /人工对账/);
    assert.equal(recoveredRuntime.executionUncertainty()?.state, 'dispatching');
    assert.equal(count(), 0, '已有 dispatching 日志意味着发送结果未知,恢复后不得猜测并重发');
  });
});

test('副作用工具:业务响应后审计失败进入 evidence_degraded,不把失败伪装成可重试', async () => {
  const rt = mkRuntime(mkTool({}), async (event) => {
    if (event === 'tool_result') throw new Error('audit sink unavailable');
  });
  await withFetchCounter(async (count) => {
    const result = await rt.invoke('create_thing', { x: 8 });
    assert.equal(result.ok, false);
    assert.match(result.text, /审计证据不完整/);
    assert.equal(rt.executionUncertainty()?.state, 'evidence_degraded');
    assert.equal(count(), 1);

    await rt.invoke('create_thing', { x: 8 });
    assert.equal(count(), 1, '业务响应后的审计故障不能触发第二次业务调用');
  });
});

test('副作用工具:发送稳定的下游幂等键,且该键与任务、主体、工具和规范化参数绑定', async () => {
  const rt = mkRuntime(mkTool({}));
  const orig = globalThis.fetch;
  let seen = '';
  globalThis.fetch = (async (_url, init) => {
    seen = String((init?.headers as Record<string, string>)['x-bailing-idempotency-key'] ?? '');
    return new Response('{"ok":true}', { status: 200 });
  }) as typeof fetch;
  try {
    await rt.invoke('create_thing', { x: 9 });
  } finally {
    globalThis.fetch = orig;
  }
  assert.equal(seen, toolCallIdempotencyKey('job1', 'create_thing', argsHash({ x: 9 }), 'u1'));
  assert.match(seen, /^[0-9a-f]{64}$/);
});

test('副作用工具:幂等哈希与实际 JSON 外发参数使用同一规范化语义', async () => {
  const rt = mkRuntime(mkTool({}));
  await withFetchCounter(async (count) => {
    await rt.invoke('create_thing', { optional: undefined });
    await rt.invoke('create_thing', {});
    assert.equal(count(), 1, 'undefined 在 JSON 对象中会省略,不能被视为另一项副作用调用');
  });
});

test('只读/声明幂等工具:不进账本,每次都真正执行(不去重)', async () => {
  const rt = mkRuntime(mkTool({ name: 'list_things', method: 'GET', readonly: true, idempotent: true }));
  await withFetchCounter(async (count) => {
    await rt.invoke('list_things', { x: 1 });
    await rt.invoke('list_things', { x: 1 });
    assert.equal(count(), 2, '只读工具重复调用应每次都执行,不缓存(拿最新数据)');
  });
});

test('工具失败:HTML 错误页只给模型短摘要,完整正文仍保留在审计', async () => {
  const html = `<!doctype html><html><body>${'internal framework error '.repeat(400)}</body></html>`;
  const audits: Array<{ event: string; detail: Record<string, unknown> }> = [];
  const tool = mkTool({ name: 'list_things', method: 'GET', readonly: true, idempotent: true });
  const runtime = buildToolRuntime({
    provider: { name: 'p', base_url: 'http://x.invalid', secret: 's', timeout_ms: 5000, rate_limit_per_min: 0, log_payload: true } as any,
    allowedTools: [tool],
    maxCalls: 10,
    onBehalfOf: 'u1',
    jobId: 'job1',
    clientAppId: 'c',
    truncateBytes: 8192,
    audit: async (event, detail) => { audits.push({ event, detail }); },
  });
  const orig = globalThis.fetch;
  globalThis.fetch = (async () => new Response(html, {
    status: 500,
    headers: { 'content-type': 'text/html; charset=utf-8' },
  })) as typeof fetch;
  try {
    const result = await runtime.invoke('list_things', {});
    assert.equal(result.ok, false);
    assert.match(result.text, /HTTP 500.*HTML 错误页/);
    assert.equal(result.text.includes('internal framework error'), false);
  } finally {
    globalThis.fetch = orig;
  }

  const audit = audits.find((entry) => entry.event === 'tool_result');
  assert.equal(audit?.detail['resp'], html);
  assert.equal(audit?.detail['resp_bytes'], html.length);
});

test('工具调用:path/header/query/body 参数按 ToolDefinition.paramIn 组装', async () => {
  const rt = mkRuntime(mkTool({
    name: 'update_staff',
    path: '/stores/{store_id}/staff/{staff_id}',
    paramIn: { store_id: 'path', staff_id: 'path', tenant: 'header', dry_run: 'query', name: 'body' },
    inputSchema: { type: 'object', properties: {} },
    readonly: true,
    idempotent: true,
  }));
  const orig = globalThis.fetch;
  let seenUrl = '';
  let seenHeaders: unknown;
  let seenBody: unknown;
  globalThis.fetch = (async (url, init) => {
    seenUrl = String(url);
    seenHeaders = init?.headers;
    seenBody = init?.body;
    return new Response('{"ok":true}', { status: 200 });
  }) as typeof fetch;
  try {
    await rt.invoke('update_staff', { store_id: 's 1', staff_id: 'u/2', tenant: 't1', dry_run: true, name: 'Alice' });
  } finally {
    globalThis.fetch = orig;
  }
  assert.match(seenUrl, /\/stores\/s%201\/staff\/u%2F2\?dry_run=true$/);
  assert.equal((seenHeaders as Record<string, string>)['tenant'], 't1');
  assert.equal(String(seenBody), JSON.stringify({ name: 'Alice' }));
});

test('参数级确认:未命中 ACC approval.when 正常执行,命中后进入审批且不外发', async () => {
  const { runtime, count } = mkRuntimeWithApprovals(mkTool({
    name: 'refund_create',
    description: '创建退款',
    scope: 'refund.write',
    confirmWhen: [{ param: 'amount', op: '>', value: 1000, label: '退款金额超过 1000' }],
    confirmPrompt: 'AI 申请退款 {amount} 元',
    inputSchema: { type: 'object', properties: { amount: { type: 'number' } } },
  }));

  await withFetchCounter(async (fetchCount) => {
    const small = await runtime.invoke('refund_create', { amount: 99 });
    assert.equal(small.ok, true);
    assert.equal(fetchCount(), 1, '小额未命中条件,应真正发出');

    const large = await runtime.invoke('refund_create', { amount: 1200 });
    assert.equal(large.ok, false);
    assert.match(large.text, /审批单/);
    assert.equal(fetchCount(), 1, '大额命中条件,应先审批、不外发');

    const malformed = await runtime.invoke('refund_create', { amount: '1200' as unknown as number });
    assert.equal(malformed.ok, false);
    assert.match(malformed.text, /参数类型/);
    assert.equal(fetchCount(), 1, '条件参数类型不符时必须拒绝,不能静默绕过审批');
  });

  const got = count();
  assert.equal(got.created, 1);
  assert.equal(got.notified, 1);
  assert.equal(got.createdSnaps[0].policy, 'confirm_when');
  assert.equal(got.createdSnaps[0].reason, '退款金额超过 1000');
  assert.equal(got.createdSnaps[0].summary, 'AI 申请退款 1200 元');
  assert.equal(got.notifiedSnaps[0].policy, 'confirm_when');
  const pending = got.audits.find((a) => a.event === 'tool_approval_pending');
  assert.deepEqual(pending?.detail['confirm_when'], {
    param: 'amount',
    op: '>',
    value: 1000,
    actual: 1200,
    reason: '退款金额超过 1000',
  });
});

test('参数级确认:布尔值不与同名字符串混同', async () => {
  const { runtime, count } = mkRuntimeWithApprovals(mkTool({
    name: 'feature_switch',
    confirmWhen: [{ param: 'enabled', op: '==', value: true, label: '启用功能需确认' }],
    inputSchema: { type: 'object', properties: { enabled: { type: 'boolean' } } },
  }));

  await withFetchCounter(async (fetchCount) => {
    const result = await runtime.invoke('feature_switch', { enabled: 'true' as unknown as boolean });
    assert.equal(result.ok, false);
    assert.match(result.text, /参数类型/);
    assert.equal(fetchCount(), 0, '类型不符时不能外发');
  });

  assert.equal(count().created, 0, '类型不符不是审批意图,必须由调用方按 schema 修正');
});

test('参数级确认:绕过编译器的无类型条件也必须在运行时拦截', async () => {
  const runtime = mkRuntime(mkTool({
    name: 'untyped_condition',
    confirmWhen: [{ param: 'submitted', op: 'exists' }],
    inputSchema: { type: 'object', properties: { submitted: {} } },
  }));

  await withFetchCounter(async (fetchCount) => {
    const result = await runtime.invoke('untyped_condition', { submitted: true });
    assert.equal(result.ok, false);
    assert.match(result.text, /审批条件参数类型/);
    assert.equal(fetchCount(), 0, '无类型审批条件不能绕过出站闸门');
  });
});