Ctrl K
ComunidadGuías…

test/cascade-native-bridge.test.js

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

Archivo original ↗Preguntar a KiBot
// v2.0.65 (#115) — Cascade native tool bridge.
//
// Validates that:
//   1. canMapAllTools correctly admits supported tools and rejects mixed/
//      unknown sets so emulation fallback fires.
//   2. Forward + reverse argument translators round-trip per known tool.
//   3. shouldUseNativeBridge is default-off (auto-on was removed). Explicit
//      WINDSURFAPI_NATIVE_TOOL_BRIDGE=1 / all_mapped still work; OFF wins.
//   4. buildAdditionalStepsFromHistory produces decodable trajectory step
//      protos when prior assistant tool_calls + tool results are present.
//   5. windsurf.parseTrajectorySteps surfaces native cascade step kinds
//      (view_file=14, run_command=28, grep_search_v2=105, find=34,
//      list_directory=15, write_to_file=23) as toolCalls with
//      cascade_native:true and the right name + args.
//   6. buildSendCascadeMessageRequest writes additional_steps to field 9
//      (repeated CortexTrajectoryStep).

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
  TOOL_MAP, CASCADE_STEP,
  canMapAllTools, partitionTools, shouldUseNativeBridge,
  buildAdditionalStep, buildAdditionalStepsFromHistory, buildNativeToolHistory, buildReverseLookup,
  parseNativeFunctionCallsFromText, NativeFunctionCallStreamParser,
} from '../src/cascade-native-bridge.js';
import { normalizeMessagesForCascade } from '../src/handlers/tool-emulation.js';
import {
  parseTrajectorySteps,
  buildSendCascadeMessageRequest,
} from '../src/windsurf.js';
import {
  parseFields, getField, getAllFields, writeMessageField, writeVarintField, writeStringField,
} from '../src/proto.js';
import { getSystemPrompts, resetSystemPrompt, setSystemPrompts } from '../src/runtime-config.js';

const fnTool = (name) => ({ type: 'function', function: { name, parameters: { type: 'object' } } });

describe('canMapAllTools', () => {
  it('admits only mature default-native tools without an explicit tool allowlist', () => {
    const prev = process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS;
    delete process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS;
    try {
      assert.equal(canMapAllTools([fnTool('Bash'), fnTool('shell_command'), fnTool('run_command')]), true);
      assert.equal(canMapAllTools([fnTool('Read'), fnTool('Bash'), fnTool('Glob')]), false);
    } finally {
      if (prev === undefined) delete process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS;
      else process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS = prev;
    }
  });

  it('rejects when ANY tool is unmapped', () => {
    assert.equal(canMapAllTools([fnTool('Read'), fnTool('get_weather')]), false);
  });

  it('rejects empty / non-array input', () => {
    assert.equal(canMapAllTools([]), false);
    assert.equal(canMapAllTools(null), false);
    assert.equal(canMapAllTools(undefined), false);
  });

  it('admits Codex-style command names by default and other native names only when allowlisted', () => {
    const prev = process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS;
    delete process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS;
    try {
      assert.equal(canMapAllTools([fnTool('run_command')]), true);
      assert.equal(canMapAllTools([fnTool('view_file'), fnTool('run_command'), fnTool('find')]), false);
      process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS = 'view_file,run_command,find';
      assert.equal(canMapAllTools([fnTool('view_file'), fnTool('run_command'), fnTool('find')]), true);
    } finally {
      if (prev === undefined) delete process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS;
      else process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS = prev;
    }
  });

  it('admits mixed Claude Code + Codex names when explicitly allowlisted', () => {
    const prev = process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS;
    process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS = 'Read,run_command,Grep';
    try {
      assert.equal(canMapAllTools([fnTool('Read'), fnTool('run_command'), fnTool('Grep')]), true);
    } finally {
      if (prev === undefined) delete process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS;
      else process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS = prev;
    }
  });
});

describe('shouldUseNativeBridge — default-off + explicit gates', () => {
  const tools = [fnTool('Read'), fnTool('Bash')];

  it('GPT family on /v1/responses route → OFF (v2.0.70 reverts auto-on for GPT — cascade native grammar makes GPT fabricate)', () => {
    // v2.0.66 had GPT auto-on, v2.0.70 reverts after end-to-end probe
    // showed markers=none and PROBE fabricated. GPT family now goes
    // through emulation + gpt_native dialect.
    assert.equal(
      shouldUseNativeBridge(tools, { modelKey: 'gpt-5.5-medium', provider: 'openai', route: 'responses' }),
      false,
    );
    assert.equal(
      shouldUseNativeBridge(tools, { modelKey: 'o4-mini', provider: 'openai', route: 'responses' }),
      false,
    );
  });

  it('GPT family on /v1/chat/completions → off (default emulation path)', () => {
    assert.equal(
      shouldUseNativeBridge(tools, { modelKey: 'gpt-5.5-medium', provider: 'openai', route: 'chat' }),
      false,
    );
  });

  it('Anthropic Claude → OFF by default (v2.0.75 #124 regression fix — bridge runs tools in REMOTE cascade sandbox, but client tools expect LOCAL execution; hang forever)', () => {
    assert.equal(
      shouldUseNativeBridge(tools, { modelKey: 'claude-sonnet-4.6', provider: 'anthropic', route: 'responses' }),
      false,
    );
    assert.equal(
      shouldUseNativeBridge(tools, { modelKey: 'claude-sonnet-4.6', provider: 'anthropic', route: 'chat' }),
      false,
    );
  });

  it('Gemini → off (no auto-on — emulation works fine for it)', () => {
    assert.equal(
      shouldUseNativeBridge(tools, { modelKey: 'gemini-2.5-flash', provider: 'google', route: 'responses' }),
      false,
    );
  });

  it('partial-mapped tools on Claude → OFF (post-v2.0.75 — same #124 logic, no client wants remote execution by default)', () => {
    assert.equal(
      shouldUseNativeBridge([fnTool('Read'), fnTool('get_weather')], {
        modelKey: 'claude-sonnet-4.6', provider: 'anthropic', route: 'responses',
      }),
      false,
    );
  });

  it('zero mapped tools → off (no point booting native path)', () => {
    assert.equal(
      shouldUseNativeBridge([fnTool('get_weather'), fnTool('update_plan')], {
        modelKey: 'gpt-5.5-medium', provider: 'openai', route: 'responses',
      }),
      false,
    );
  });

  it('explicit env override forces on for any mapped tool set (deployer opting into remote execution)', () => {
    const orig = process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE;
    const toolsOrig = process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS;
    process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE = '1';
    process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS = 'Read,Bash';
    try {
      assert.equal(
        shouldUseNativeBridge(tools, { modelKey: 'claude-sonnet-4-6', provider: 'anthropic', route: 'chat' }),
        true,
      );
      // GPT too, when explicitly enabled.
      assert.equal(
        shouldUseNativeBridge(tools, { modelKey: 'gpt-5.5-medium', provider: 'openai', route: 'responses' }),
        true,
      );
    } finally {
      if (orig === undefined) delete process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE;
      else process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE = orig;
      if (toolsOrig === undefined) delete process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS;
      else process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS = toolsOrig;
    }
  });

  it('all_mapped mode enables only when every function tool maps', () => {
    const orig = process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE;
    const toolsOrig = process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS;
    process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE = 'all_mapped';
    process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS = 'Read,Bash,Grep,Glob';
    try {
      assert.equal(
        shouldUseNativeBridge([fnTool('Read'), fnTool('Bash'), fnTool('Grep'), fnTool('Glob')], {
          modelKey: 'claude-sonnet-4.6', provider: 'anthropic', route: 'chat',
        }),
        true,
      );
      assert.equal(
        shouldUseNativeBridge([fnTool('Read'), fnTool('Bash'), fnTool('update_plan')], {
          modelKey: 'claude-sonnet-4.6', provider: 'anthropic', route: 'chat',
        }),
        false,
      );
    } finally {
      if (orig === undefined) delete process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE;
      else process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE = orig;
      if (toolsOrig === undefined) delete process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS;
      else process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS = toolsOrig;
    }
  });

  it('OFF override beats explicit on', () => {
    const onOrig = process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE;
    const offOrig = process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_OFF;
    process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE = '1';
    process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_OFF = '1';
    try {
      assert.equal(
        shouldUseNativeBridge(tools, { modelKey: 'gpt-5.5-medium', provider: 'openai', route: 'responses' }),
        false,
      );
    } finally {
      if (onOrig === undefined) delete process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE;
      else process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE = onOrig;
      if (offOrig === undefined) delete process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_OFF;
      else process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_OFF = offOrig;
    }
  });
});

describe('TOOL_MAP — forward / reverse round-trip per kind', () => {
  it('Read ↔ view_file preserves file_path / offset / limit', () => {
    const original = { file_path: '/abs/path/foo.ts', offset: 10, limit: 200 };
    const cascade = TOOL_MAP.Read.forward(original);
    assert.equal(cascade.absolute_path_uri, 'file:///abs/path/foo.ts');
    assert.equal(cascade.offset, 10);
    assert.equal(cascade.limit, 200);
    const back = TOOL_MAP.Read.reverse(cascade);
    assert.deepEqual(back, original);
  });

  it('Bash ↔ run_command preserves command (and cwd if present)', () => {
    const original = { command: 'npm test --silent' };
    const cascade = TOOL_MAP.Bash.forward(original);
    assert.equal(cascade.command_line, 'npm test --silent');
    const back = TOOL_MAP.Bash.reverse(cascade);
    assert.equal(back.command, 'npm test --silent');
    // cwd round-trip
    const withCwd = TOOL_MAP.Bash.forward({ command: 'ls', cwd: '/tmp' });
    assert.equal(TOOL_MAP.Bash.reverse(withCwd).cwd, '/tmp');
  });

  it('Grep ↔ grep_search_v2 preserves pattern + flags', () => {
    const original = { pattern: 'foo', '-i': true, head_limit: 50, glob: '*.js' };
    const cascade = TOOL_MAP.Grep.forward(original);
    assert.equal(cascade.pattern, 'foo');
    assert.equal(cascade.case_insensitive, true);
    assert.equal(cascade.head_limit, 50);
    assert.equal(cascade.glob, '*.js');
    const back = TOOL_MAP.Grep.reverse(cascade);
    assert.equal(back.pattern, 'foo');
    assert.equal(back['-i'], true);
    assert.equal(back.head_limit, 50);
    assert.equal(back.glob, '*.js');
  });

  it('Glob ↔ find preserves pattern + path', () => {
    const cascade = TOOL_MAP.Glob.forward({ pattern: '**/*.ts', path: 'src' });
    assert.equal(cascade.pattern, '**/*.ts');
    assert.equal(cascade.search_directory, 'src');
    const back = TOOL_MAP.Glob.reverse(cascade);
    assert.equal(back.pattern, '**/*.ts');
    assert.equal(back.path, 'src');
  });

  it('Write ↔ write_to_file preserves file_path + content', () => {
    const cascade = TOOL_MAP.Write.forward({ file_path: '/tmp/x.txt', content: 'hello\n' });
    assert.equal(cascade.target_file_uri, 'file:///tmp/x.txt');
    assert.deepEqual(cascade.code_content, ['hello\n']);
    const back = TOOL_MAP.Write.reverse(cascade);
    assert.equal(back.file_path, '/tmp/x.txt');
    assert.equal(back.content, 'hello\n');
  });
});

describe('buildReverseLookup', () => {
  it('inverts caller tools by cascade kind', () => {
    const lookup = buildReverseLookup([fnTool('Read'), fnTool('Bash'), fnTool('Grep')]);
    assert.deepEqual(lookup.get('view_file'), ['Read']);
    assert.deepEqual(lookup.get('run_command'), ['Bash']);
    assert.deepEqual(lookup.get('grep_search_v2'), ['Grep']);
  });

  it('returns empty map for empty input', () => {
    const lookup = buildReverseLookup([]);
    assert.equal(lookup.size, 0);
  });

  it('handles caller declaring multiple tools that map to same kind', () => {
    const lookup = buildReverseLookup([fnTool('Read'), fnTool('view_file'), fnTool('read_file')]);
    const list = lookup.get('view_file');
    assert.ok(list.includes('Read'));
    assert.ok(list.includes('view_file'));
    assert.ok(list.includes('read_file'));
  });
});

describe('parseNativeFunctionCallsFromText', () => {
  it('maps Claude provider-native read_file invoke back to caller Read', () => {
    const lookup = buildReverseLookup([fnTool('Read')]);
    const out = parseNativeFunctionCallsFromText(
      'I will inspect it.\n<function_calls>\n<invoke name="read_file">\n<parameter name="path">/tmp/a.txt</parameter>\n</invoke>\n</function_calls>',
      lookup,
    );
    assert.equal(out.toolCalls.length, 1);
    assert.equal(out.toolCalls[0].name, 'Read');
    assert.deepEqual(JSON.parse(out.toolCalls[0].argumentsJson), { file_path: '/tmp/a.txt' });
    assert.ok(!out.text.includes('<function_calls>'));
    assert.ok(!out.text.includes('<invoke'));
  });

  it('drops unknown invokes instead of leaking XML or inventing caller tools', () => {
    const lookup = buildReverseLookup([fnTool('Read')]);
    const text = '<function_calls><invoke name="unknown_tool"><parameter name="x">1</parameter></invoke></function_calls>';
    const out = parseNativeFunctionCallsFromText(text, lookup);
    assert.equal(out.toolCalls.length, 0);
    assert.equal(out.text, '');
  });

  it('drops known invokes when the caller did not declare a matching tool', () => {
    const lookup = buildReverseLookup([fnTool('Bash')]);
    const text = 'before <function_calls><invoke name="read_file"><parameter name="path">README.md</parameter></invoke></function_calls> after';
    const out = parseNativeFunctionCallsFromText(text, lookup);
    assert.equal(out.toolCalls.length, 0);
    assert.equal(out.text, 'before  after');
  });

  it('stream parser withholds provider-native XML until it can emit a tool_call', () => {
    const lookup = buildReverseLookup([fnTool('Read')]);
    const parser = new NativeFunctionCallStreamParser(lookup);
    const a = parser.feed('<function_calls>\n<invoke name="read_file">\n');
    assert.equal(a.text, '');
    assert.equal(a.toolCalls.length, 0);
    const b = parser.feed('<parameter name="path">README.md</parameter>\n');
    assert.equal(b.text, '');
    assert.equal(b.toolCalls.length, 0);
    const c = parser.feed('</invoke>\n</function_calls>');
    assert.equal(c.text, '');
    assert.equal(c.toolCalls.length, 1);
    assert.equal(c.toolCalls[0].name, 'Read');
    assert.deepEqual(JSON.parse(c.toolCalls[0].argumentsJson), { file_path: 'README.md' });
    const tail = parser.flush();
    assert.equal(tail.text, '');
    assert.equal(tail.toolCalls.length, 0);
  });

  it('stream parser holds partial opening tags across chunk boundaries', () => {
    const lookup = buildReverseLookup([fnTool('Read')]);
    const parser = new NativeFunctionCallStreamParser(lookup);
    const a = parser.feed('prefix <fun');
    assert.equal(a.text, 'prefix ');
    assert.equal(a.toolCalls.length, 0);
    const b = parser.feed('ction_calls><invoke name="read_file"><parameter name="path">a.txt</parameter></invoke></function_calls> suffix');
    assert.equal(b.text, ' suffix');
    assert.equal(b.toolCalls.length, 1);
    assert.deepEqual(JSON.parse(b.toolCalls[0].argumentsJson), { file_path: 'a.txt' });
  });

  it('stream parser drops an incomplete XML block once it exceeds 64KiB', () => {
    const lookup = buildReverseLookup([fnTool('Read')]);
    const parser = new NativeFunctionCallStreamParser(lookup);
    parser.feed('<function_calls><invoke name="read_file">');
    const overflow = parser.feed('x'.repeat(70_000));
    assert.deepEqual(overflow, { text: '', toolCalls: [] });
    assert.equal(parser.buffer, '');
    const later = parser.feed('after-cap text');
    assert.equal(later.text, 'after-cap text');
  });

  it('stream parser does not drop a large content delta that is not an XML block', () => {
    const lookup = buildReverseLookup([fnTool('Read')]);
    const parser = new NativeFunctionCallStreamParser(lookup);
    const body = 'plain '.repeat(20_000);
    const out = parser.feed(body);
    assert.equal(out.text, body);
    assert.equal(out.toolCalls.length, 0);
  });

  it('stream parser drops incomplete XML on flush', () => {
    const lookup = buildReverseLookup([fnTool('Read')]);
    const parser = new NativeFunctionCallStreamParser(lookup);
    assert.deepEqual(parser.feed('start <function_calls><invoke name="read_file">'), { text: 'start ', toolCalls: [] });
    const tail = parser.flush();
    assert.equal(tail.text, '');
    assert.equal(tail.toolCalls.length, 0);
  });

  it('non-stream parser drops dangling function_calls blocks', () => {
    const lookup = buildReverseLookup([fnTool('Read')]);
    const out = parseNativeFunctionCallsFromText('prefix <function_calls><invoke name="read_file">', lookup);
    assert.equal(out.text, 'prefix');
    assert.equal(out.toolCalls.length, 0);
  });
});

describe('buildAdditionalStep / buildAdditionalStepsFromHistory', () => {
  it('view_file step encodes CortexStepType=8 with the body on oneof field 14', () => {
    const buf = buildAdditionalStep('view_file', {
      absolute_path_uri: 'file:///foo.ts',
      content: 'console.log("hi")',
    }, { observed: true });
    assert.ok(Buffer.isBuffer(buf));
    const fields = parseFields(buf);
    const typeField = getField(fields, 1, 0);
    assert.equal(typeField.value, 8, 'CortexStepType.VIEW_FILE is 8, not the oneof field 14');
    const oneof = getField(fields, 14, 2);
    assert.ok(oneof, 'view_file body should be on field 14');
    const body = parseFields(oneof.value);
    assert.equal(getField(body, 1, 2).value.toString('utf8'), 'file:///foo.ts');
    assert.equal(getField(body, 4, 2).value.toString('utf8'), 'console.log("hi")');
  });

  it('run_command step puts command_line on field 23 + combined_output on 21', () => {
    const buf = buildAdditionalStep('run_command', {
      command_line: 'echo hi',
      full_output: 'hi\n',
      exit_code: 0,
    }, { observed: true });
    const fields = parseFields(buf);
    assert.equal(getField(fields, 1, 0).value, 21, 'CortexStepType.RUN_COMMAND is 21');
    const body = parseFields(getField(fields, 28, 2).value);
    assert.equal(getField(body, 23, 2).value.toString('utf8'), 'echo hi');
    const combined = parseFields(getField(body, 21, 2).value);
    assert.equal(getField(combined, 1, 2).value.toString('utf8'), 'hi\n');
  });

  it('full assistant→tool history → trajectory step buffers', () => {
    const messages = [
      { role: 'user', content: 'find me a file' },
      {
        role: 'assistant',
        content: null,
        tool_calls: [{
          id: 'call_1',
          type: 'function',
          function: { name: 'Read', arguments: JSON.stringify({ file_path: '/etc/hosts' }) },
        }],
      },
      { role: 'tool', tool_call_id: 'call_1', content: '127.0.0.1 localhost\n' },
    ];
    const steps = buildAdditionalStepsFromHistory(messages);
    assert.equal(steps.length, 1);
    const fields = parseFields(steps[0]);
    assert.equal(getField(fields, 1, 0).value, 8, 'CortexStepType.VIEW_FILE is 8');
    const body = parseFields(getField(fields, 14, 2).value);
    assert.equal(getField(body, 4, 2).value.toString('utf8'), '127.0.0.1 localhost\n');
  });

  it('WebFetch history encodes tool result as read_url_content web_document', () => {
    const messages = [
      {
        role: 'assistant',
        content: null,
        tool_calls: [{
          id: 'call_fetch',
          type: 'function',
          function: { name: 'WebFetch', arguments: JSON.stringify({ url: 'https://example.com/docs' }) },
        }],
      },
      { role: 'tool', tool_call_id: 'call_fetch', content: 'fetched document body' },
    ];
    const steps = buildAdditionalStepsFromHistory(messages);
    assert.equal(steps.length, 1);
    const fields = parseFields(steps[0]);
    assert.equal(getField(fields, 1, 0).value, 31, 'CortexStepType.READ_URL_CONTENT is 31');
    const body = parseFields(getField(fields, 40, 2).value);
    assert.equal(getField(body, 1, 2).value.toString('utf8'), 'https://example.com/docs');
    assert.equal(getField(body, 5, 2), null, 'read_url_content must not encode legacy top-level summary field 5');
    const doc = parseFields(getField(body, 2, 2).value);
    assert.equal(getField(doc, 2, 2).value.toString('utf8'), 'fetched document body');
    assert.equal(getField(doc, 3, 2).value.toString('utf8'), 'https://example.com/docs');
  });

  it('skips unmapped tool_calls (fall back to emulation path)', () => {
    const messages = [
      {
        role: 'assistant',
        tool_calls: [{
          id: 'call_x',
          function: { name: 'get_weather', arguments: '{}' },
        }],
      },
      { role: 'tool', tool_call_id: 'call_x', content: 'sunny' },
    ];
    const steps = buildAdditionalStepsFromHistory(messages);
    assert.equal(steps.length, 0);
  });
});

// V-13 (KH-034) — history truth: a step is only complete when the caller
// actually reported a result; a failed shell command keeps its non-zero
// status; unmapped history stays visible to the emulation path; and a
// reused cascade receives only the rounds it has not already seen.
describe('native history truth (V-13)', () => {
  const callMsg = (id, name, args, extra = {}) => ({
    role: 'assistant',
    content: null,
    tool_calls: [{
      id,
      type: 'function',
      function: { name, arguments: JSON.stringify(args) },
    }],
    ...extra,
  });
  const resultMsg = (id, content, extra = {}) => ({ role: 'tool', tool_call_id: id, content, ...extra });

  function decodeRunCommand(buf) {
    const fields = parseFields(buf);
    assert.equal(getField(fields, 1, 0).value, 21, 'CortexStepType.RUN_COMMAND is 21');
    return {
      status: getField(fields, 4, 0).value,
      body: parseFields(getField(fields, 28, 2).value),
    };
  }

  it('an unobserved call never becomes DONE and a failed Bash keeps its non-zero exit_code', () => {
    // Layer 1: the encoder itself refuses to stamp DONE without an observation.
    assert.equal(
      buildAdditionalStep('run_command', { command_line: 'ls' }),
      null,
      'no observation means no completed step',
    );

    // Layer 2: history conversion drops the unobserved call instead of
    // telling the planner it ran with empty output.
    const unobserved = buildAdditionalStepsFromHistory([
      { role: 'user', content: 'go' },
      callMsg('call_unobserved', 'Bash', { command: 'ls' }),
    ]);
    assert.equal(unobserved.length, 0, 'an assistant tool_call with no result is not a DONE step');

    // A failed Bash result carries its exit status. The step is DONE because
    // the command completed — not because it succeeded.
    const failedText = 'Exit code: 2\nOutput:\nls: cannot access /nope';
    const failed = buildAdditionalStepsFromHistory([
      { role: 'user', content: 'go' },
      callMsg('call_failed', 'Bash', { command: 'ls /nope' }),
      resultMsg('call_failed', failedText),
    ]);
    assert.equal(failed.length, 1);
    const decoded = decodeRunCommand(failed[0]);
    assert.equal(decoded.status, 3, 'status DONE means the step completed, not that it succeeded');
    assert.equal(getField(decoded.body, 6, 0).value, 2, 'a failed Bash keeps its non-zero exit_code');
    const combined = parseFields(getField(decoded.body, 21, 2).value);
    assert.equal(getField(combined, 1, 2).value.toString('utf8'), failedText);
    assert.equal(
      getField(decoded.body, 4, 2),
      null,
      'one authoritative output field: combined_output only, no duplicated stdout',
    );

    // Success keeps 0 — the fix must not turn every command into a failure.
    const ok = buildAdditionalStepsFromHistory([
      { role: 'user', content: 'go' },
      callMsg('call_ok', 'Bash', { command: 'true' }),
      resultMsg('call_ok', 'Exit code: 0\n'),
    ]);
    assert.equal(getField(decodeRunCommand(ok[0]).body, 6, 0).value, 0);

    // Anthropic-style failures carry no numeric code; they must still not be
    // replayed as success (proto3 reads an absent field as 0).
    const toolError = buildAdditionalStepsFromHistory([
      { role: 'user', content: 'go' },
      callMsg('call_err', 'Bash', { command: 'nope' }),
      resultMsg('call_err', '[tool error] bash: nope: command not found'),
    ]);
    assert.equal(
      getField(decodeRunCommand(toolError[0]).body, 6, 0).value,
      1,
      'a signalled failure without a numeric code is encoded non-zero, never 0',
    );

    const flagged = buildAdditionalStepsFromHistory([
      { role: 'user', content: 'go' },
      callMsg('call_flag', 'Bash', { command: 'nope' }),
      resultMsg('call_flag', 'bash: nope: command not found', { is_error: true }),
    ]);
    assert.equal(getField(decodeRunCommand(flagged[0]).body, 6, 0).value, 1);

    // codex-style structured shell results keep their exit status too.
    const json = buildAdditionalStepsFromHistory([
      { role: 'user', content: 'go' },
      callMsg('call_json', 'Bash', { command: 'nope' }),
      resultMsg('call_json', JSON.stringify({ output: 'boom', metadata: { exit_code: 127 } })),
    ]);
    assert.equal(getField(decodeRunCommand(json[0]).body, 6, 0).value, 127);
  });

  it('a reused cascade receives only the new tool round, not the already-injected prefix', () => {
    const messages = [
      { role: 'user', content: 'u1' },
      callMsg('call_1', 'Read', { file_path: '/a' }),
      resultMsg('call_1', 'A'),
      { role: 'assistant', content: 'read a' },
      callMsg('call_2', 'Read', { file_path: '/b' }),
      resultMsg('call_2', 'B'),
    ];

    // A fresh cascade never saw the earlier round, so it gets the full history.
    const fresh = buildNativeToolHistory(messages);
    assert.equal(fresh.steps.length, 2);
    // The full build exposes where the new round starts, so the wire can
    // slice instead of encoding the history a second time.
    assert.equal(fresh.deltaStart, 1);
    assert.deepEqual(fresh.steps.slice(fresh.deltaStart), buildAdditionalStepsFromHistory(messages, { reuse: true }));

    // The reused cascade already holds call_1's step; re-injecting it would
    // duplicate the observation in its trajectory.
    const delta = buildAdditionalStepsFromHistory(messages, { reuse: true });
    assert.equal(delta.length, 1, 'only the newest pending round is new');
    const body = parseFields(getField(parseFields(delta[0]), 14, 2).value);
    assert.equal(getField(body, 4, 2).value.toString('utf8'), 'B');

    // A reused cascade whose newest turn is a user message has nothing new.
    assert.equal(
      buildAdditionalStepsFromHistory([...messages, { role: 'user', content: 'next' }], { reuse: true }).length,
      0,
    );

    // The trailing round is only a delta when it has observations; an
    // unanswered proposal is not a completed step.
    assert.equal(
      buildAdditionalStepsFromHistory(messages.slice(0, 5), { reuse: true }).length,
      0,
    );
  });

  it('unmapped and orphan tool history survives in the emulation message list', () => {
    const messages = [
      { role: 'user', content: 'u1' },
      {
        role: 'assistant',
        content: null,
        tool_calls: [
          { id: 'call_mapped', type: 'function', function: { name: 'Bash', arguments: '{"command":"ls"}' } },
          { id: 'call_unmapped', type: 'function', function: { name: 'get_weather', arguments: '{"city":"x"}' } },
        ],
      },
      resultMsg('call_mapped', 'Exit code: 0\nok'),
      resultMsg('call_unmapped', 'sunny'),
      resultMsg('call_orphan', 'orphan output'),
    ];

    const history = buildNativeToolHistory(messages);
    assert.equal(history.steps.length, 1, 'only the mapped+observed call becomes a step');

    // The mapped pair belongs to the bridge and must not be duplicated into
    // the text history; the unmapped pair and the orphan result are retained.
    const retained = history.emulationMessages;
    assert.equal(retained.length, 4, 'user + assistant(unmapped call) + unmapped result + orphan result');
    assert.deepEqual(retained[1].tool_calls.map(tc => tc.id), ['call_unmapped']);
    assert.equal(retained[2].tool_call_id, 'call_unmapped');
    assert.equal(retained[3].tool_call_id, 'call_orphan');

    const folded = normalizeMessagesForCascade(retained, [fnTool('get_weather')], { injectUserPreamble: false });
    const text = folded.map(m => (typeof m.content === 'string' ? m.content : '')).join('\n');
    assert.match(text, /sunny/, 'the unmapped result reaches the model as emulated history');
    assert.match(text, /orphan output/, 'an orphan result is not silently dropped');
    assert.doesNotMatch(text, /Exit code: 0/, 'the bridge-owned result is not duplicated as text');
  });
});

describe('parseTrajectorySteps — native step recognition', () => {
  // Helpers to build a CortexTrajectoryStep envelope by hand. We avoid
  // calling buildAdditionalStep here so tests double-cover encoder / decoder
  // independently.
  const wrapStep = (typeEnum, oneofField, bodyBuf) =>
    Buffer.concat([
      writeVarintField(1, typeEnum),
      writeVarintField(4, 3), // status DONE
      writeMessageField(oneofField, bodyBuf),
    ]);

  // GetCascadeTrajectoryStepsResponse is `repeated CortexTrajectoryStep steps = 1`
  const wrapResponse = (...stepBufs) =>
    Buffer.concat(stepBufs.map(b => writeMessageField(1, b)));

  it('view_file step → toolCall with name=view_file + arguments + result', () => {
    const body = Buffer.concat([
      writeStringField(1, 'file:///abs/foo.ts'),
      writeVarintField(11, 0),
      writeVarintField(12, 100),
      writeStringField(4, 'console.log("hi")\n'),
    ]);
    const resp = wrapResponse(wrapStep(8, 14, body));
    const steps = parseTrajectorySteps(resp);
    assert.equal(steps.length, 1);
    const calls = steps[0].toolCalls.filter(tc => tc.cascade_native);
    assert.equal(calls.length, 1);
    assert.equal(calls[0].name, 'view_file');
    const args = JSON.parse(calls[0].argumentsJson);
    assert.equal(args.absolute_path_uri, 'file:///abs/foo.ts');
    assert.equal(args.limit, 100);
    assert.equal(calls[0].result, 'console.log("hi")\n');
  });

  it('run_command step → toolCall with combined_output observation', () => {
    const combinedOutput = writeStringField(1, 'hi\n');
    const body = Buffer.concat([
      writeStringField(23, 'echo hi'),
      writeMessageField(21, combinedOutput),
    ]);
    const resp = wrapResponse(wrapStep(21, 28, body));
    const steps = parseTrajectorySteps(resp);
    const calls = steps[0].toolCalls.filter(tc => tc.cascade_native);
    assert.equal(calls.length, 1);
    assert.equal(calls[0].name, 'run_command');
    assert.equal(calls[0].result, 'hi\n');
    const args = JSON.parse(calls[0].argumentsJson);
    assert.equal(args.command_line, 'echo hi');
  });

  it('grep_search_v2 step (field 105) → toolCall name grep_search_v2', () => {
    const body = Buffer.concat([
      writeStringField(2, 'todo'),
      writeStringField(3, 'src'),
      writeStringField(15, 'src/foo.ts:10:// todo\n'),
    ]);
    const resp = wrapResponse(wrapStep(91, 105, body));
    const steps = parseTrajectorySteps(resp);
    const calls = steps[0].toolCalls.filter(tc => tc.cascade_native);
    assert.equal(calls.length, 1);
    assert.equal(calls[0].name, 'grep_search_v2');
    assert.equal(calls[0].result, 'src/foo.ts:10:// todo\n');
  });

  it('list_directory step → children joined as result', () => {
    const body = Buffer.concat([
      writeStringField(1, 'file:///src'),
      writeStringField(2, 'foo.ts'),
      writeStringField(2, 'bar.ts'),
    ]);
    const resp = wrapResponse(wrapStep(9, 15, body));
    const steps = parseTrajectorySteps(resp);
    const calls = steps[0].toolCalls.filter(tc => tc.cascade_native);
    assert.equal(calls.length, 1);
    assert.equal(calls[0].name, 'list_directory');
    assert.equal(calls[0].result, 'foo.ts\nbar.ts');
  });

  it('multiple native steps in same trajectory all surface', () => {
    const viewBody = Buffer.concat([writeStringField(1, 'file:///a.ts'), writeStringField(4, 'A')]);
    const cmdBody = Buffer.concat([writeStringField(23, 'ls'), writeMessageField(21, writeStringField(1, 'a.ts\n'))]);
    const resp = wrapResponse(wrapStep(8, 14, viewBody), wrapStep(21, 28, cmdBody));
    const steps = parseTrajectorySteps(resp);
    assert.equal(steps.length, 2);
    assert.equal(steps[0].toolCalls.filter(tc => tc.cascade_native).length, 1);
    assert.equal(steps[1].toolCalls.filter(tc => tc.cascade_native).length, 1);
  });
});

describe('buildSendCascadeMessageRequest — additional_steps on field 9', () => {
  it('omits XML reinforcement from a Kimi preamble', () => {
    setSystemPrompts({ toolReinforcement: 'Use this exact format: <tool_call>{"name":"...","arguments":{...}}</tool_call>' });
    try {
      const proto = buildSendCascadeMessageRequest(
        'k', 'cid', 'hi', 12345, 'MODEL_TEST', 'sess',
        { toolPreamble: '<|tool_calls_section_begin|>\nAvailable tools:\n<|tool_calls_section_end|>' },
      );
      const top = parseFields(proto);
      const cfg = parseFields(getField(top, 5, 2).value);
      const planner = parseFields(getField(cfg, 1, 2).value);
      const conversational = parseFields(getField(planner, 2, 2).value);
      const section = parseFields(getField(conversational, 12, 2).value);
      const text = getField(section, 2, 2).value.toString('utf8');

      assert.match(text, /<\|tool_calls_section_begin\|>/);
      assert.doesNotMatch(text, /Use this exact format:\s*<tool_call>/i);
    } finally {
      resetSystemPrompt('toolReinforcement');
    }
  });

  // The test above INJECTS a reinforcement containing `<tool_call>`, so it proves the
  // suppression works — but not that it ever runs. Detection keys off that marker being
  // present, so if the shipped default stops carrying it the suppression silently becomes a
  // no-op and the test above still passes. #252 did exactly that: it added the dialect-scoped
  // suppression AND replaced the default with a generic sentence, and the two cancelled out.
  // Measured after the merge: conflictsWithKimiPreamble never fired on the default config.
  //
  // Net effect was the opposite of the PR's intent — the collision went away because the
  // marker was gone everywhere, which also stripped the format example from every non-Kimi
  // dialect. Pin the precondition so the mechanism cannot be hollowed out again.
  it('the shipped default carries the marker the Kimi suppression keys off', () => {
    resetSystemPrompt('toolReinforcement');
    const { toolReinforcement } = getSystemPrompts();
    assert.match(
      toolReinforcement,
      /<\s*tool_call\s*>/i,
      'default toolReinforcement must contain <tool_call>: it is both the format example for '
        + 'non-Kimi dialects and the trigger conflictsWithKimiPreamble() detects',
    );

    // And end-to-end: with the DEFAULT (not an injected string), a Kimi preamble must still
    // suppress it. This is the assertion #252 needed and did not have.
    const proto = buildSendCascadeMessageRequest(
      'k', 'cid', 'hi', 12345, 'MODEL_TEST', 'sess',
      { toolPreamble: '<|tool_calls_section_begin|>\nAvailable tools:\n<|tool_calls_section_end|>' },
    );
    const top = parseFields(proto);
    const cfg = parseFields(getField(top, 5, 2).value);
    const planner = parseFields(getField(cfg, 1, 2).value);
    const conversational = parseFields(getField(planner, 2, 2).value);
    const text = getField(parseFields(getField(conversational, 12, 2).value), 2, 2).value.toString('utf8');
    assert.match(text, /<\|tool_calls_section_begin\|>/, 'the Kimi preamble itself must survive');
    assert.doesNotMatch(
      text,
      /<\s*tool_call\s*>/i,
      'the XML reinforcement must be suppressed under a Kimi preamble, using the real default',
    );
  });

  it('includes each step as repeated field 9', () => {
    const stepA = buildAdditionalStep('view_file', { absolute_path_uri: 'file:///a', content: 'A' }, { observed: true });
    const stepB = buildAdditionalStep('run_command', { command_line: 'ls', full_output: 'a\n' }, { observed: true });
    const proto = buildSendCascadeMessageRequest(
      'k', 'cid', 'hi', 12345, 'MODEL_TEST', 'sess',
      { additionalSteps: [stepA, stepB] },
    );
    const fields = parseFields(proto);
    const additional = getAllFields(fields, 9).filter(f => f.wireType === 2);
    assert.equal(additional.length, 2, 'two repeated additional_steps expected on field 9');
  });

  it('omits field 9 when no additionalSteps provided', () => {
    const proto = buildSendCascadeMessageRequest('k', 'cid', 'hi', 12345, 'MODEL_TEST', 'sess', {});
    const fields = parseFields(proto);
    assert.equal(getAllFields(fields, 9).length, 0);
  });

  it('nativeMode=true switches planner_mode to DEFAULT (1) and adds tool_config', () => {
    const proto = buildSendCascadeMessageRequest('k', 'cid', 'hi', 12345, 'MODEL_TEST', 'sess', {
      nativeMode: true,
      nativeAllowlist: ['view_file', 'run_command'],
    });
    const top = parseFields(proto);
    const cfgField = getField(top, 5, 2);
    assert.ok(cfgField, 'cascade_config required on field 5');
    const cfg = parseFields(cfgField.value);
    const planner = parseFields(getField(cfg, 1, 2).value);
    // CascadePlannerConfig.tool_config = field 13 (CascadeToolConfig)
    const toolCfg = getField(planner, 13, 2);
    assert.ok(toolCfg, 'CascadePlannerConfig.tool_config (field 13) should be set in nativeMode');
    const tc = parseFields(toolCfg.value);
    const allow = getAllFields(tc, 32).map(f => f.value.toString('utf8'));
    assert.ok(allow.includes('view_file'));
    assert.ok(allow.includes('run_command'));
    // conversational planner sub-config (field 2) → planner_mode (field 4) = DEFAULT (1)
    const conv = parseFields(getField(planner, 2, 2).value);
    const mode = getField(conv, 4, 0);
    assert.equal(mode.value, 1, 'planner_mode in nativeMode should be DEFAULT (1)');
    assert.equal(getField(conv, 10, 2), null, 'nativeMode must not write a no-tool tool_calling_section');
    assert.equal(getField(conv, 12, 2), null, 'nativeMode must not write a no-tool additional_instructions_section');
  });

  it('native allowlist experiment aliases still enable the matching tool sub-configs', () => {
    const proto = buildSendCascadeMessageRequest('k', 'cid', 'hi', 12345, 'MODEL_TEST', 'sess', {
      nativeMode: true,
      nativeAllowlist: ['read_file', 'grep_v2', 'list_dir'],
    });
    const top = parseFields(proto);
    const cfg = parseFields(getField(top, 5, 2).value);
    const planner = parseFields(getField(cfg, 1, 2).value);
    const tc = parseFields(getField(planner, 13, 2).value);
    const allow = getAllFields(tc, 32).map(f => f.value.toString('utf8'));
    assert.deepEqual(allow, ['read_file', 'grep_v2', 'list_dir']);
    assert.ok(getField(tc, 10, 2), 'read_file should still enable ViewFileToolConfig field 10');
    assert.ok(getField(tc, 33, 2), 'grep_v2 should still enable GrepV2ToolConfig field 33');
    assert.ok(getField(tc, 19, 2), 'list_dir should enable ListDirToolConfig field 19');
    assert.equal(getField(tc, 5, 2), null, 'list_dir experiment should not also enable FindToolConfig');
  });

  it('native tool config raw overrides inject exact sub-config bytes for protocol experiments', () => {
    process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_CONFIG_RAW = 'read_file:0a03616263;grep_v2:base64:EgJwYQ==';
    try {
      const proto = buildSendCascadeMessageRequest('k', 'cid', 'hi', 12345, 'MODEL_TEST', 'sess', {
        nativeMode: true,
        nativeAllowlist: ['read_file', 'grep_v2'],
      });
      const top = parseFields(proto);
      const cfg = parseFields(getField(top, 5, 2).value);
      const planner = parseFields(getField(cfg, 1, 2).value);
      const tc = parseFields(getField(planner, 13, 2).value);
      assert.equal(getField(tc, 10, 2).value.toString('hex'), '0a03616263');
      assert.equal(getField(tc, 33, 2).value.toString('hex'), '12027061');
      assert.deepEqual(getAllFields(tc, 32).map(f => f.value.toString('utf8')), ['read_file', 'grep_v2']);
    } finally {
      delete process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_CONFIG_RAW;
    }
  });

  it('native tool config raw overrides can inject unknown top-level fields for matrix experiments', () => {
    process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_CONFIG_RAW = 'field42:0a03776562;field40:';
    try {
      const proto = buildSendCascadeMessageRequest('k', 'cid', 'hi', 12345, 'MODEL_TEST', 'sess', {
        nativeMode: true,
        nativeAllowlist: ['search_web', 'read_url_content'],
      });
      const top = parseFields(proto);
      const cfg = parseFields(getField(top, 5, 2).value);
      const planner = parseFields(getField(cfg, 1, 2).value);
      const tc = parseFields(getField(planner, 13, 2).value);
      assert.equal(getField(tc, 42, 2).value.toString('hex'), '0a03776562');
      assert.equal(getField(tc, 40, 2).value.length, 0);
      assert.deepEqual(getAllFields(tc, 32).map(f => f.value.toString('utf8')), ['search_web', 'read_url_content']);
    } finally {
      delete process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_CONFIG_RAW;
    }
  });

  it('native allowlist emits confirmed WebSearch/WebFetch tool-config fields', () => {
    const proto = buildSendCascadeMessageRequest('k', 'cid', 'hi', 12345, 'MODEL_TEST', 'sess', {
      nativeMode: true,
      nativeAllowlist: ['search_web', 'read_url_content'],
    });
    const top = parseFields(proto);
    const cfg = parseFields(getField(top, 5, 2).value);
    const planner = parseFields(getField(cfg, 1, 2).value);
    const tc = parseFields(getField(planner, 13, 2).value);
    assert.equal(getField(tc, 13, 2).value.length, 0, 'search_web should use SearchWebToolConfig field 13');
    assert.equal(getField(tc, 37, 2).value.length, 0, 'read_url_content should use ReadUrlContentToolConfig field 37');
    assert.deepEqual(getAllFields(tc, 32).map(f => f.value.toString('utf8')), ['search_web', 'read_url_content']);
  });

  it('native tool config raw overrides support web tool aliases', () => {
    process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_CONFIG_RAW = 'search_web:0800;read_url_content:0a00';
    try {
      const proto = buildSendCascadeMessageRequest('k', 'cid', 'hi', 12345, 'MODEL_TEST', 'sess', {
        nativeMode: true,
        nativeAllowlist: ['search_web', 'read_url_content'],
      });
      const top = parseFields(proto);
      const cfg = parseFields(getField(top, 5, 2).value);
      const planner = parseFields(getField(cfg, 1, 2).value);
      const tc = parseFields(getField(planner, 13, 2).value);
      assert.equal(getField(tc, 13, 2).value.toString('hex'), '0800');
      assert.equal(getField(tc, 37, 2).value.toString('hex'), '0a00');
    } finally {
      delete process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_CONFIG_RAW;
    }
  });

  it('nativeMode=true can carry environment facts without tool emulation schema', () => {
    const proto = buildSendCascadeMessageRequest('k', 'cid', 'hi', 12345, 'MODEL_TEST', 'sess', {
      nativeMode: true,
      nativeAllowlist: ['view_file'],
      nativeEnvironment: '- Working directory: /repo\n- Platform: linux',
    });
    const top = parseFields(proto);
    const cfg = parseFields(getField(top, 5, 2).value);
    const planner = parseFields(getField(cfg, 1, 2).value);
    const conv = parseFields(getField(planner, 2, 2).value);
    assert.equal(getField(conv, 4, 0).value, 1, 'nativeMode still uses DEFAULT planner');
    assert.equal(getField(conv, 10, 2), null, 'native env must not inject tool_calling_section schemas');
    const additional = getField(conv, 12, 2);
    assert.ok(additional, 'native env should be written to additional_instructions_section');
    const section = parseFields(additional.value);
    const text = getField(section, 2, 2)?.value?.toString('utf8') || '';
    assert.match(text, /Working directory: \/repo/);
    assert.match(text, /built-in IDE tools/);
    assert.doesNotMatch(text, /You have access to the following functions/);
    assert.doesNotMatch(text, /<tool_call>/);
  });

  it('nativeMode=false (default) keeps planner_mode = NO_TOOL (3) and skips tool_config', () => {
    const proto = buildSendCascadeMessageRequest('k', 'cid', 'hi', 12345, 'MODEL_TEST', 'sess', {});
    const top = parseFields(proto);
    const cfg = parseFields(getField(top, 5, 2).value);
    const planner = parseFields(getField(cfg, 1, 2).value);
    assert.equal(getField(planner, 13, 2), null, 'tool_config should NOT be set when nativeMode is off');
    const conv = parseFields(getField(planner, 2, 2).value);
    assert.equal(getField(conv, 4, 0).value, 3);
  });
});

describe('CASCADE_STEP type constants — sanity', () => {
  it('matches proto field numbers used in oneof', () => {
    assert.equal(CASCADE_STEP.view_file.typeEnum, 8);
    assert.equal(CASCADE_STEP.run_command.typeEnum, 21);
    assert.equal(CASCADE_STEP.grep_search_v2.typeEnum, 91);
    assert.equal(CASCADE_STEP.find.typeEnum, 25);
    assert.equal(CASCADE_STEP.list_directory.typeEnum, 9);
    assert.equal(CASCADE_STEP.write_to_file.typeEnum, 16);
  });
});

// ─── v2.0.66 (#115) — partition mode + codex CLI mapping ──────────────

describe('partitionTools — v2.0.66 mixed-mapping splitter', () => {
  it('splits mapped vs unmapped on a real codex CLI 0.128 toolset (web_search default-off)', () => {
    // Captured live from `dump-codex-tools.mjs`: codex CLI 0.128 declares
    // these 11 tools by default. Native web search/fetch remain opt-in in
    // the bridge allowlist, so the default mapped set stays shell-only.
    const codexTools = [
      'shell_command', 'update_plan', 'request_user_input',
      'apply_patch', 'web_search', 'view_image',
      'spawn_agent', 'send_input', 'resume_agent', 'wait_agent', 'close_agent',
    ].map(fnTool);
    const part = partitionTools(codexTools);
    assert.equal(part.hasAny, true);
    const mappedNames = part.mapped.map(t => t.function.name).sort();
    assert.deepEqual(mappedNames, ['shell_command']);
    assert.equal(part.unmapped.length, 10);
    assert.ok(part.unmapped.find(t => t.function.name === 'apply_patch'));
    assert.ok(part.unmapped.find(t => t.function.name === 'update_plan'));
    assert.ok(part.unmapped.find(t => t.function.name === 'web_search'));
  });

  it('returns hasAny=false when no tool maps', () => {
    const part = partitionTools([fnTool('get_weather'), fnTool('rng')]);
    assert.equal(part.hasAny, false);
    assert.equal(part.mapped.length, 0);
    assert.equal(part.unmapped.length, 2);
  });

  it('Claude Code-style tools keep Read/Glob out of the default native scope', () => {
    const part = partitionTools([fnTool('Read'), fnTool('Bash'), fnTool('Glob')]);
    assert.equal(part.hasAny, true);
    assert.deepEqual(part.mapped.map(t => t.function.name), ['Bash']);
    assert.deepEqual(part.unmapped.map(t => t.function.name), ['Read', 'Glob']);
  });

  it('skips non-function entries gracefully', () => {
    const part = partitionTools([
      fnTool('Read'),
      { type: 'web_search' },
      { type: 'function' },                 // missing function.name
      { type: 'function', function: { name: '' } },
    ]);
    assert.equal(part.mapped.length, 0);
    assert.equal(part.unmapped.length, 1);
  });
});

describe('TOOL_MAP — codex CLI 0.128 shell_command mapping (v2.0.66)', () => {
  it('shell_command ↔ run_command preserves command + workdir', () => {
    const fwd = TOOL_MAP.shell_command.forward({
      command: 'pytest -x',
      workdir: 'D:/Project/foo',
      timeout_ms: 30000,
    });
    assert.equal(fwd.command_line, 'pytest -x');
    assert.equal(fwd.cwd, 'D:/Project/foo');
    assert.equal(fwd.blocking, true);
    const back = TOOL_MAP.shell_command.reverse(fwd);
    assert.equal(back.command, 'pytest -x');
    assert.equal(back.workdir, 'D:/Project/foo');
  });

  it('shell_command reverse maps cascade combined_output → codex shell schema', () => {
    // Simulates the trajectory step → tool_call reverse direction the
    // proxy uses when surfacing a cascade-side run_command back to the
    // codex CLI client.
    const back = TOOL_MAP.shell_command.reverse({
      command_line: 'echo hi',
      cwd: '/tmp',
      stdout: 'hi\n',
    });
    assert.equal(back.command, 'echo hi');
    assert.equal(back.workdir, '/tmp');
  });
});

describe('canMapAllTools (legacy strict gate, kept for compat)', () => {
  it('still returns false when ANY tool is unmapped', () => {
    assert.equal(canMapAllTools([fnTool('Read'), fnTool('get_weather')]), false);
    assert.equal(canMapAllTools([fnTool('Read'), fnTool('Bash'), fnTool('Glob')]), false);
  });
});

describe('mergeReasoningEffortIntoModel — v2.0.66 reasoning_effort fold-in', () => {
  it('codex CLI shape (reasoning.effort) folds into bare gpt-5.5', async () => {
    const { mergeReasoningEffortIntoModel } = await import('../src/handlers/chat.js');
    assert.equal(
      mergeReasoningEffortIntoModel('gpt-5.5', { reasoning: { effort: 'xhigh' } }),
      'gpt-5.5-xhigh',
    );
    assert.equal(
      mergeReasoningEffortIntoModel('gpt-5.5', { reasoning: { effort: 'high' } }),
      'gpt-5.5-high',
    );
  });

  it('OpenAI-style (top-level reasoning_effort) also works', async () => {
    const { mergeReasoningEffortIntoModel } = await import('../src/handlers/chat.js');
    assert.equal(
      mergeReasoningEffortIntoModel('gpt-5.5', { reasoning_effort: 'low' }),
      'gpt-5.5-low',
    );
  });

  it('an explicit effort suffix is authoritative over the effort field (CompatClients F1)', async () => {
    const { mergeReasoningEffortIntoModel } = await import('../src/handlers/chat.js');
    // This case used to pin the OPPOSITE (suffix stripped and replaced), which
    // is the defect Codex hit: it sends reasoning.effort=medium on every turn,
    // so gpt-6-luna-max was served as gpt-6-luna-medium. An explicit tier in
    // the model id is the caller's selection and must win in both directions —
    // a -medium request must not be promoted either.
    assert.equal(
      mergeReasoningEffortIntoModel('gpt-6-luna-max', { reasoning: { effort: 'medium' } }),
      'gpt-6-luna-max',
    );
    assert.equal(
      mergeReasoningEffortIntoModel('gpt-5.5-xhigh', { reasoning: { effort: 'medium' } }),
      'gpt-5.5-xhigh',
    );
    assert.equal(
      mergeReasoningEffortIntoModel('claude-opus-4-8-medium', { reasoning: { effort: 'xhigh' } }),
      'claude-opus-4-8-medium',
    );
    // No suffix in the request → the effort field still supplies the tier.
    assert.equal(
      mergeReasoningEffortIntoModel('claude-opus-4.8', { reasoning_effort: 'max' }),
      'claude-opus-4.8-max',
    );
  });

  it('returns input unchanged when effort missing', async () => {
    const { mergeReasoningEffortIntoModel } = await import('../src/handlers/chat.js');
    assert.equal(mergeReasoningEffortIntoModel('gpt-5.5', {}), 'gpt-5.5');
    assert.equal(mergeReasoningEffortIntoModel('gpt-5.5', { reasoning: {} }), 'gpt-5.5');
    assert.equal(mergeReasoningEffortIntoModel('gpt-5.5', { reasoning: { effort: '' } }), 'gpt-5.5');
  });

  it('returns input unchanged for unknown effort strings (catalog has no -bogus variant)', async () => {
    const { mergeReasoningEffortIntoModel } = await import('../src/handlers/chat.js');
    assert.equal(mergeReasoningEffortIntoModel('gpt-5.5', { reasoning_effort: 'bogus' }), 'gpt-5.5');
  });

  it('returns input unchanged when merged model is not in catalog', async () => {
    const { mergeReasoningEffortIntoModel } = await import('../src/handlers/chat.js');
    // claude models don't have effort suffixes — the merged form
    // `claude-sonnet-4.6-xhigh` doesn't exist in models.js, so the helper
    // refuses to swap.
    assert.equal(
      mergeReasoningEffortIntoModel('claude-sonnet-4.6', { reasoning: { effort: 'xhigh' } }),
      'claude-sonnet-4.6',
    );
  });

  it('handles `minimal` by mapping to `none` (windsurf catalog naming)', async () => {
    const { mergeReasoningEffortIntoModel } = await import('../src/handlers/chat.js');
    // gpt-5.5-none is in the catalog (the lowest tier alias)
    assert.equal(
      mergeReasoningEffortIntoModel('gpt-5.5', { reasoning_effort: 'minimal' }),
      'gpt-5.5-none',
    );
  });
});