Ctrl K
ComunidadGuías…

src/cascade-native-bridge.js

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

Archivo original ↗Preguntar a KiBot
/**
 * v2.0.65 — Cascade native tool bridge (#115 root-cause fix).
 *
 * Translates between OpenAI-shaped client tools (Read/Bash/Glob/Grep/...) and
 * Cascade's built-in IDE step kinds (view_file/run_command/find/grep_search_v2/...).
 * Runtime enablement is deliberately narrower than TOOL_MAP: by default only
 * the Bash/run_command family is allowlisted. Read/Grep/Glob/WebSearch/WebFetch
 * remain lab/protocol mappings until an operator explicitly allowlists them
 * behind model/account/API-key gates.
 *
 * Why this layer exists
 * ─────────────────────
 * v2.0.62-v2.0.64 (#115) shipped dialect/anti-refusal infra under the
 * NO_TOOL planner mode + tool-prompt emulation umbrella. Real-world GPT
 * traces (Codex CLI, gpt-5.x) still surfaced markers=none on non-trivial
 * turns — the gateway's baked system prompt outweighs anything we inject
 * via additional_instructions_section. The tools the gateway DOES respect
 * are Cascade's own — view_file, run_command, grep_search_v2, find — because
 * those names appear inside the planner's training distribution as first-
 * class function-calling tokens, not as proxy-injected text.
 *
 * The bridge never enables planner_mode=DEFAULT on its own — that path
 * triggers server-side workspace mocking ("/tmp/windsurf-workspace path
 * leaks", #98 / pre-v2.0.64 stall_warm bursts). Instead, the bridge:
 *
 *   1. Forward translates the caller's OpenAI tool inventory + tool history
 *      into Cascade-vocabulary names so the gateway sees a familiar
 *      tool list and a sequence of completed cascade-style steps.
 *
 *   2. Reverse translates each trajectory step (view_file, run_command,
 *      grep_search_v2, find, list_directory) the planner emits back into
 *      the caller's original OpenAI tool name (Read, Bash, Grep, Glob, ...).
 *
 *   3. Partition (v2.0.66): mapped tools go through trajectory-step
 *      injection; unmapped tools keep the emulation preamble. Both can
 *      coexist on one request. `all_mapped` mode still refuses mixed sets.
 *      The file-top "fall back entirely" rule is the old v2.0.65 gate.
 *
 * Activation:
 *   WINDSURFAPI_NATIVE_TOOL_BRIDGE=1
 *     Force on for any request with at least one mapped tool.
 *   WINDSURFAPI_NATIVE_TOOL_BRIDGE=all_mapped
 *     Enable only when every declared function tool maps to a Cascade-native
 *     kind. This avoids partial native + prompt-emulation mixing.
 *
 * Default OFF until field-tested. Do not enable this for local IDE agents
 * unless remote Windsurf-workspace execution is the intended behavior.
 */

import {
  writeStringField, writeMessageField, writeVarintField, writeBoolField, writeBytesField,
  parseFields, getField, getAllFields,
} from './proto.js';

// ─── Cascade step type enums ───────────────────────────────────────
//
// CortexStepType enum values from exa.cortex_pb.proto. The `type` field is
// `CortexTrajectoryStep.type` (field 1), whose enum values are NOT the oneof
// field numbers: e.g. VIEW_FILE=8 but the `view_file` body rides field 14.
// Writing the oneof number into `type` labels every step as a different kind
// (14 is USER_INPUT, 28 is COMMAND_STATUS, 40 is TOOL_CALL_PROPOSAL). The
// body field still uses the oneof number; keep the two separate.
export const CASCADE_STEP = {
  // step kind → { typeEnum, oneofField }
  view_file:       { typeEnum: 8,   oneofField: 14  },
  list_directory:  { typeEnum: 9,   oneofField: 15  },
  write_to_file:   { typeEnum: 16,  oneofField: 23  },
  run_command:     { typeEnum: 21,  oneofField: 28  },
  propose_code:    { typeEnum: 24,  oneofField: 32  },
  find:            { typeEnum: 25,  oneofField: 34  },
  read_url_content:{ typeEnum: 31,  oneofField: 40  },
  grep_search:     { typeEnum: 7,   oneofField: 13  },
  grep_search_v2:  { typeEnum: 91,  oneofField: 105 },
  // v2.0.70 — search_web (CortexStepSearchWeb proto field 42).
  search_web:      { typeEnum: 33,  oneofField: 42  },
};

// CortexStepStatus — used for the step.status field (CortexTrajectoryStep
// field 4). DONE=3 marks a step as complete-and-observed; UNSPECIFIED=0
// would leave the planner thinking work is still in progress.
export const CASCADE_STEP_STATUS_DONE = 3;

const DEFAULT_NATIVE_BRIDGE_TOOLS = new Set([
  // Default scope is intentionally narrow: real smoke has only proven the
  // command path stable enough for opt-in production canaries. Read/Grep/Glob
  // translators remain in TOOL_MAP for protocol matrix work, but must be
  // explicitly allowlisted with WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS before
  // they are routed through the native bridge.
  'Bash', 'shell_command', 'run_command',
]);

// ─── argument translators ─────────────────────────────────────────
//
// Each translator maps OpenAI-style arguments (the JSON object the caller
// puts in tool_calls[].function.arguments) to and from cascade step
// fields. They MUST be pure — identical args in produce identical cascade
// step proto bytes, and a round-trip translator(reverse(forward(args))) is
// the identity. The reverse direction is exercised for two reasons:
// (a) the planner emits trajectory steps in cascade vocabulary and we have
// to surface them as OpenAI tool_calls with arguments the caller's schema
// validator accepts; (b) tests assert lossless round-trip per tool.

function safeJsonParse(s) {
  if (typeof s !== 'string' || !s) return {};
  try { const v = JSON.parse(s); return v && typeof v === 'object' ? v : {}; }
  catch { return {}; }
}

function csvSetEnv(name) {
  return new Set(String(process.env[name] || '')
    .split(',')
    .map(s => s.trim())
    .filter(Boolean));
}

function csvListEnv(name) {
  return String(process.env[name] || '')
    .split(',')
    .map(s => s.trim())
    .filter(Boolean);
}

export function getNativeBridgeConfigStatus() {
  return {
    mode: String(process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE || '').trim(),
    off: process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_OFF === '1',
    tools: csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS'),
    models: csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_MODELS'),
    providers: csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_PROVIDERS'),
    routes: csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_ROUTES'),
    callers: csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_CALLERS'),
    allowlistNameOverrides: csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_ALLOWLIST_NAMES'),
    hasApiKeyGate: csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_API_KEYS').length > 0,
    hasAccountGate: csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_ACCOUNTS').length > 0,
    hasRawConfig: !!String(process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_CONFIG_RAW || '').trim(),
  };
}

function patternMatches(pattern, value, caseInsensitive = false) {
  if (!pattern || !value) return false;
  const p = caseInsensitive ? String(pattern).toLowerCase() : String(pattern);
  const v = caseInsensitive ? String(value).toLowerCase() : String(value);
  if (p === '*' || p === 'all') return true;
  if (p.endsWith('*')) return v.startsWith(p.slice(0, -1));
  return p === v;
}

function envGateAllows(envName, values, { caseInsensitive = false } = {}) {
  const patterns = csvListEnv(envName);
  if (!patterns.length) return true;
  const candidates = values.map(v => String(v || '').trim()).filter(Boolean);
  if (!candidates.length) return false;
  return patterns.some(p => candidates.some(v => patternMatches(p, v, caseInsensitive)));
}

function isNativeBridgeToolAllowed(name) {
  const configured = csvSetEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_TOOLS');
  const n = String(name || '');
  if (!n) return false;
  if (!configured.size) return DEFAULT_NATIVE_BRIDGE_TOOLS.has(n);
  if (configured.has('*') || configured.has('all')) return Object.prototype.hasOwnProperty.call(TOOL_MAP, n);
  return configured.has(n);
}

export function nativeBridgeGrayGateStatus({
  modelKey = '',
  model = '',
  provider = '',
  route = '',
  callerKey = '',
} = {}) {
  const callerKeys = [callerKey];
  if (String(callerKey || '').includes(':api_key_allowed')) {
    callerKeys.push(String(callerKey || '').replace(':api_key_allowed', ''));
  }
  if (!envGateAllows('WINDSURFAPI_NATIVE_TOOL_BRIDGE_MODELS', [modelKey, model], { caseInsensitive: true })) {
    return { ok: false, errorType: 'native_bridge_model_not_allowed' };
  }
  if (!envGateAllows('WINDSURFAPI_NATIVE_TOOL_BRIDGE_PROVIDERS', [provider], { caseInsensitive: true })) {
    return { ok: false, errorType: 'native_bridge_provider_not_allowed' };
  }
  if (!envGateAllows('WINDSURFAPI_NATIVE_TOOL_BRIDGE_ROUTES', [route], { caseInsensitive: true })) {
    return { ok: false, errorType: 'native_bridge_route_not_allowed' };
  }
  if (!envGateAllows('WINDSURFAPI_NATIVE_TOOL_BRIDGE_CALLERS', callerKeys)) {
    return { ok: false, errorType: 'native_bridge_caller_not_allowed' };
  }
  if (csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_API_KEYS').length && !String(callerKey || '').includes(':api_key_allowed')) {
    return { ok: false, errorType: 'native_bridge_api_key_not_allowed' };
  }
  return { ok: true };
}

export function hasNativeBridgeAccountGate() {
  return csvListEnv('WINDSURFAPI_NATIVE_TOOL_BRIDGE_ACCOUNTS').length > 0;
}

export function isNativeBridgeAccountAllowed(account = {}) {
  return envGateAllows('WINDSURFAPI_NATIVE_TOOL_BRIDGE_ACCOUNTS', [
    account.accountId,
    account.id,
    account.accountEmail,
    account.email,
  ], { caseInsensitive: true });
}

function unescapeXmlText(s) {
  return String(s || '')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/&amp;/g, '&');
}

function buildFileUri(absolutePath) {
  if (typeof absolutePath !== 'string' || !absolutePath) return '';
  // Cascade's view_file uses absolute_path_uri — `file://` prefix optional
  // depending on LS version. Both forms are accepted in the wild; we leave
  // already-prefixed paths intact and add the prefix to bare ones.
  if (/^file:\/\//.test(absolutePath)) return absolutePath;
  if (/^[a-zA-Z]:[\\/]/.test(absolutePath) || absolutePath.startsWith('/')) {
    return `file://${absolutePath.replace(/\\/g, '/')}`;
  }
  // Relative path — leave as-is. Caller's environment block tells the
  // planner what cwd to resolve against.
  return absolutePath;
}

function stripFileUri(uri) {
  if (typeof uri !== 'string') return '';
  return uri.replace(/^file:\/\//, '');
}

// ── Read / view_file ────────────────────────────────────────────
function forwardReadArgs(args) {
  const file_path = args.file_path || args.path || args.absolute_path || '';
  const offset = Number(args.offset) || 0;
  const limit = Number(args.limit) || 0;
  return {
    absolute_path_uri: buildFileUri(file_path),
    offset,
    limit,
  };
}
function reverseReadArgs(cascade) {
  return {
    file_path: stripFileUri(cascade.absolute_path_uri || ''),
    ...(cascade.offset ? { offset: cascade.offset } : {}),
    ...(cascade.limit ? { limit: cascade.limit } : {}),
  };
}

// ── Bash / run_command ──────────────────────────────────────────
function forwardBashArgs(args) {
  const command = args.command || args.shell_command || '';
  return {
    command_line: String(command),
    cwd: typeof args.cwd === 'string' ? args.cwd : '',
    blocking: true,
  };
}
function reverseBashArgs(cascade) {
  return {
    command: cascade.command_line || cascade.proposed_command_line || '',
    ...(cascade.cwd ? { cwd: cascade.cwd } : {}),
  };
}

// ── Glob / find ─────────────────────────────────────────────────
function forwardGlobArgs(args) {
  return {
    pattern: args.pattern || '',
    search_directory: args.path || args.cwd || '',
  };
}
function reverseGlobArgs(cascade) {
  const path = cascade.search_directory || stripFileUri(cascade.directory_path_uri || '');
  return {
    // Cascade may satisfy a Glob request via list_directory when the
    // experiment allowlist maps find -> list_dir. That step has no glob
    // pattern field, so keep the caller's schema valid with a broad match.
    pattern: cascade.pattern || (cascade.directory_path_uri ? '*' : ''),
    ...(path ? { path } : {}),
  };
}

// ── Grep / grep_search_v2 ───────────────────────────────────────
function forwardGrepArgs(args) {
  return {
    pattern: args.pattern || '',
    path: args.path || '',
    glob: args.glob || '',
    output_mode: args.output_mode || 'files_with_matches',
    case_insensitive: !!args['-i'],
    multiline: !!args.multiline,
    type: args.type || '',
    head_limit: Number(args.head_limit) || 0,
    lines_after: Number(args['-A']) || 0,
    lines_before: Number(args['-B']) || 0,
    lines_both: Number(args['-C'] ?? args.context) || 0,
  };
}
function reverseGrepArgs(cascade) {
  const out = { pattern: cascade.pattern || '' };
  if (cascade.path) out.path = cascade.path;
  if (cascade.glob) out.glob = cascade.glob;
  if (cascade.output_mode) out.output_mode = cascade.output_mode;
  if (cascade.case_insensitive) out['-i'] = true;
  if (cascade.multiline) out.multiline = true;
  if (cascade.type) out.type = cascade.type;
  if (cascade.head_limit) out.head_limit = cascade.head_limit;
  if (cascade.lines_after) out['-A'] = cascade.lines_after;
  if (cascade.lines_before) out['-B'] = cascade.lines_before;
  if (cascade.lines_both) out['-C'] = cascade.lines_both;
  return out;
}

// ── Write / write_to_file ──────────────────────────────────────
function forwardWriteArgs(args) {
  const file_path = args.file_path || args.path || '';
  const content = args.content || '';
  return {
    target_file_uri: buildFileUri(file_path),
    code_content: typeof content === 'string' ? [content] : Array.isArray(content) ? content : [String(content)],
  };
}
function reverseWriteArgs(cascade) {
  const lines = Array.isArray(cascade.code_content) ? cascade.code_content : [];
  return {
    file_path: stripFileUri(cascade.target_file_uri || ''),
    content: lines.join(''),
  };
}

// ── list_dir / list_directory ──────────────────────────────────
function forwardListDirArgs(args) {
  return {
    directory_path_uri: buildFileUri(args.path || args.directory_path || args.cwd || ''),
  };
}
function reverseListDirArgs(cascade) {
  return {
    path: stripFileUri(cascade.directory_path_uri || ''),
  };
}

// ── identity (when caller already speaks cascade vocabulary) ───
function identityArgs(x) { return { ...x }; }

// ── Edit / MultiEdit ↔ propose_code (v2.0.70) ──────────────────
// Claude Code's Edit tool: { file_path, old_string, new_string,
//   replace_all? }. MultiEdit: { file_path, edits: [{old_string,
//   new_string}, ...] }. Both map onto cascade's ActionSpecCommand
// (field 1 of ActionSpec, which is field 1 of CortexStepProposeCode).
//
// ActionSpecCommand.replacement_chunks is a repeated ReplacementChunk
// { target_content=1, replacement_content=2, allow_multiple=3 }.
//
// We forward to a lightweight intermediate dict (cascade-side keys)
// so build_propose_code_body can pull them and serialise to proto.
// MultiEdit collapses into one ActionSpecCommand with multiple chunks.
function forwardClaudeEditArgs(args) {
  const file_path = args.file_path || args.path || '';
  const chunks = [];
  if (Array.isArray(args.edits)) {
    for (const e of args.edits) {
      chunks.push({
        target: typeof e?.old_string === 'string' ? e.old_string : '',
        replacement: typeof e?.new_string === 'string' ? e.new_string : '',
        allow_multiple: !!e?.replace_all,
      });
    }
  } else {
    chunks.push({
      target: typeof args.old_string === 'string' ? args.old_string : '',
      replacement: typeof args.new_string === 'string' ? args.new_string : '',
      allow_multiple: !!args.replace_all,
    });
  }
  return { target_file_uri: buildFileUri(file_path), replacement_chunks: chunks, instruction: '' };
}
function reverseClaudeEditArgs(cascade) {
  if (cascade && typeof cascade.__raw_edit === 'string') return safeJsonParse(cascade.__raw_edit);
  const file_path = stripFileUri(cascade.target_file_uri || '');
  const chunks = Array.isArray(cascade.replacement_chunks) ? cascade.replacement_chunks : [];
  if (chunks.length <= 1) {
    const c = chunks[0] || {};
    return {
      file_path,
      old_string: c.target || '',
      new_string: c.replacement || '',
      ...(c.allow_multiple ? { replace_all: true } : {}),
    };
  }
  return {
    file_path,
    edits: chunks.map(c => ({
      old_string: c.target || '',
      new_string: c.replacement || '',
      ...(c.allow_multiple ? { replace_all: true } : {}),
    })),
  };
}

// ── web_search ↔ search_web (v2.0.70) ─────────────────────────
// CortexStepSearchWeb { query=1, domain=3, web_documents=2, ... }
// Caller's `web_search` declares { query, domains? }. domains[] ≥ 1
// → we pick the first (cascade only takes a single `domain` string).
function forwardWebSearchArgs(args) {
  return {
    query: args.query || args.q || '',
    domain: Array.isArray(args.domains) && args.domains.length ? args.domains[0] : (args.domain || ''),
  };
}
function reverseWebSearchArgs(cascade) {
  return {
    query: cascade.query || '',
    ...(cascade.domain ? { domains: [cascade.domain] } : {}),
  };
}

// ── WebFetch ↔ read_url_content (v2.0.93) ─────────────────────────
// CortexStepReadUrlContent { url=1, web_document=2, resolved_url=3, ... }
function forwardWebFetchArgs(args) {
  return { url: args.url || args.uri || args.link || '' };
}
function reverseWebFetchArgs(cascade) {
  return { url: cascade.url || '' };
}

// ── apply_patch ↔ write_to_file (single-file fan-out, v2.0.70) ──
// codex CLI's `apply_patch` ships a multi-file patch in a custom
// grammar. Full fan-out (parsing the patch and emitting one
// write_to_file per file) is outside the scope of a single-step
// translator — the cascade trajectory expects the proxy to fan out
// at message-build time. For now reverse maps `__raw_apply_patch` so
// the caller's tool_call round-trips, and forward refuses (returns
// null) so partition mode falls back to emulation for this tool. The
// caller still gets a working apply_patch via emulation toolPreamble.
function forwardApplyPatchArgs(args) {
  // Sentinel that build_step_body recognises and skips (returns null
  // buffer, partition treats as unmapped).
  return { __apply_patch_unmappable: true, raw: typeof args === 'string' ? args : (args.input || JSON.stringify(args || {})) };
}
function reverseApplyPatchArgs(cascade) {
  if (cascade && typeof cascade.raw === 'string') return { input: cascade.raw };
  return cascade || {};
}

// ─── OpenAI tool name → cascade kind table ──────────────────────────
//
// Keys are the EXACT tool name the caller declares in tools[].function.name.
// Casing matters — Claude Code uses TitleCase (Read, Bash); Codex CLI uses
// snake_case (view_file, run_command). Both are honored here.

export const TOOL_MAP = {
  // Claude Code
  Read:       { kind: 'view_file',      forward: forwardReadArgs,         reverse: reverseReadArgs },
  Bash:       { kind: 'run_command',    forward: forwardBashArgs,         reverse: reverseBashArgs },
  Glob:       { kind: 'find',           forward: forwardGlobArgs,         reverse: reverseGlobArgs },
  Grep:       { kind: 'grep_search_v2', forward: forwardGrepArgs,         reverse: reverseGrepArgs },
  Write:      { kind: 'write_to_file',  forward: forwardWriteArgs,        reverse: reverseWriteArgs },
  // v2.0.70: Edit/MultiEdit really map to cascade propose_code with
  // ReplacementChunks now. Old pass-through (forwardEditArgs) kept as
  // fallback for the rare case the cascade body builder rejects the
  // chunk shape (claude code emits a tool_call with no old_string).
  Edit:       { kind: 'propose_code',   forward: forwardClaudeEditArgs,   reverse: reverseClaudeEditArgs },
  MultiEdit:  { kind: 'propose_code',   forward: forwardClaudeEditArgs,   reverse: reverseClaudeEditArgs },
  WebSearch:  { kind: 'search_web',     forward: forwardWebSearchArgs,    reverse: reverseWebSearchArgs },
  // v2.0.93 — ToolSearch is Claude Code's web search tool (same as WebSearch)
  ToolSearch: { kind: 'search_web',     forward: forwardWebSearchArgs,    reverse: reverseWebSearchArgs },
  // v2.0.93 — WebFetch is Claude Code's URL fetch tool (map to read_url_content)
  WebFetch:   { kind: 'read_url_content', forward: forwardWebFetchArgs,   reverse: reverseWebFetchArgs },

  // Codex CLI (already speaks cascade-ish vocabulary)
  view_file:       { kind: 'view_file',      forward: identityArgs, reverse: identityArgs },
  run_command:     { kind: 'run_command',    forward: forwardRunCommandPassThrough, reverse: reverseRunCommandPassThrough },
  grep_v2:         { kind: 'grep_search_v2', forward: identityArgs, reverse: identityArgs },
  grep_search:     { kind: 'grep_search_v2', forward: identityArgs, reverse: identityArgs },
  grep_search_v2:  { kind: 'grep_search_v2', forward: identityArgs, reverse: identityArgs },
  find:            { kind: 'find',           forward: identityArgs, reverse: identityArgs },
  list_dir:        { kind: 'list_directory', forward: forwardListDirArgs, reverse: reverseListDirArgs },
  list_directory:  { kind: 'list_directory', forward: forwardListDirArgs, reverse: reverseListDirArgs },
  write_to_file:   { kind: 'write_to_file',  forward: identityArgs, reverse: identityArgs },

  // Common synonyms surfaced by other clients
  read_file:       { kind: 'view_file',      forward: forwardReadArgs,  reverse: reverseReadArgs },
  shell:           { kind: 'run_command',    forward: forwardBashArgs,  reverse: reverseBashArgs },

  // ── Codex CLI 0.128 toolset (#115 v2.0.66) ───────────────────────
  // Captured from a real codex exec request body via
  // scripts/probes/dump-codex-tools.mjs. codex CLI declares 11 tools by
  // default; only `shell_command` has a clean cascade-native equivalent.
  // The rest (apply_patch / update_plan / request_user_input / web_search /
  // view_image / spawn_agent / send_input / resume_agent / wait_agent /
  // close_agent) intentionally stay OFF this map — partition mode routes
  // unmapped tools through the existing toolPreamble emulation path.
  // Adding apply_patch / web_search here was tried in v2.0.66 dev but
  // their forward translators have no lossless cascade target (apply_patch
  // is multi-file patches, write_to_file is single-target; web_search ≠
  // read_url_content), so they'd produce garbage cascade steps.
  shell_command:   { kind: 'run_command',    forward: forwardCodexShellArgs, reverse: reverseCodexShellArgs },
  // v2.0.70 — codex `web_search` maps to cascade search_web. codex
  // declares it as type:'web_search' but responses.js flattens to
  // function/web_search before it reaches this map.
  web_search:      { kind: 'search_web',     forward: forwardWebSearchArgs,  reverse: reverseWebSearchArgs },
};

// Edit / MultiEdit translate to propose_code. ActionSpec / ActionResult are
// nested messages with their own schemas — for v2.0.65 we degrade Edit to a
// pass-through that preserves args inside CustomToolSpec.arguments_json so
// the planner sees a structured record without us shipping the full
// ActionSpec proto. The caller's reverse translator restores its original
// payload because we keep the arguments_json verbatim.
function forwardEditArgs(args) {
  return { __raw_edit: JSON.stringify(args || {}) };
}
function reverseEditArgs(cascade) {
  if (cascade && typeof cascade.__raw_edit === 'string') return safeJsonParse(cascade.__raw_edit);
  return cascade || {};
}

// run_command pass-through: cascade and Codex both name the param
// "command" / "command_line" — accept either, normalise on the cascade side.
function forwardRunCommandPassThrough(args) {
  return {
    command_line: args.command_line || args.command || '',
    cwd: args.cwd || '',
    blocking: true,
  };
}
function reverseRunCommandPassThrough(cascade) {
  return {
    command_line: cascade.command_line || cascade.proposed_command_line || '',
    ...(cascade.cwd ? { cwd: cascade.cwd } : {}),
  };
}

// ── Codex CLI 0.128 codex-specific arg shapes ───────────────────────
// codex CLI's `shell_command` declares: {command:"<cmd>", workdir?:"...",
// timeout_ms?:int}. cascade run_command takes command_line + cwd. The
// reverse direction restores codex's expected shape so when the proxy
// surfaces a cascade-side run_command step back to codex CLI, codex
// picks it up as a normal shell_command tool_call.
function forwardCodexShellArgs(args) {
  return {
    command_line: args.command || args.command_line || '',
    cwd: args.workdir || args.cwd || '',
    blocking: true,
  };
}
function reverseCodexShellArgs(cascade) {
  return {
    command: cascade.command_line || cascade.proposed_command_line || '',
    ...(cascade.cwd ? { workdir: cascade.cwd } : {}),
  };
}


// ─── Caller-tools introspection ─────────────────────────────────────

/**
 * canMapAllTools(tools) — returns true when EVERY caller-declared tool is
 * present in TOOL_MAP. Kept for backwards compatibility / strict-mode
 * callers; v2.0.66 prefers partitionTools() because real-world clients
 * (codex CLI 0.128 declares 11 tools, only 1 maps cleanly) almost never
 * pass the all-mapped bar.
 */
export function canMapAllTools(tools) {
  if (!Array.isArray(tools) || tools.length === 0) return false;
  for (const t of tools) {
    if (t?.type !== 'function') return false;
    const name = t.function?.name;
    if (!name || !TOOL_MAP[name] || !isNativeBridgeToolAllowed(name)) return false;
  }
  return true;
}

/**
 * partitionTools(tools) — split the caller's tools[] into:
 *   - mapped:   tools with a TOOL_MAP entry (route through cascade native
 *               trajectory steps)
 *   - unmapped: tools without a mapping (route through the existing
 *               emulation toolPreamble path)
 *
 * Both subsets coexist in the same request: mapped tools enable native
 * planner_mode=DEFAULT + tool_allowlist while unmapped tool definitions
 * are still injected into additional_instructions_section so the planner
 * can fall through to text-protocol emit when it needs them.
 *
 * Returns { mapped: Tool[], unmapped: Tool[], hasAny: bool }.
 */
export function partitionTools(tools) {
  const mapped = [];
  const unmapped = [];
  if (Array.isArray(tools)) {
    for (const t of tools) {
      if (t?.type !== 'function' || !t.function?.name) continue;
      if (TOOL_MAP[t.function.name] && isNativeBridgeToolAllowed(t.function.name)) mapped.push(t);
      else unmapped.push(t);
    }
  }
  return { mapped, unmapped, hasAny: mapped.length > 0 };
}

/**
 * Build the inverse lookup table: cascade kind → list of caller tool names
 * that map onto it. Used by translateCascadeStepToToolCall to pick the
 * caller-visible name when the planner emits a step (the caller might have
 * declared `Read` OR `view_file` for the same kind; we emit the one they
 * actually declared).
 */
export function buildReverseLookup(callerTools) {
  const out = new Map();
  if (!Array.isArray(callerTools)) return out;
  for (const t of callerTools) {
    if (t?.type !== 'function' || !t.function?.name) continue;
    const name = t.function.name;
    const entry = TOOL_MAP[name];
    if (!entry) continue;
    addReverseLookup(out, entry.kind, name);
    const allowlistKind = cascadeKindForAllowlistName(nativeAllowlistNameForTool(name));
    if (allowlistKind && allowlistKind !== entry.kind) {
      addReverseLookup(out, allowlistKind, name);
    }
  }
  return out;
}

function addReverseLookup(out, kind, name) {
  if (!kind || !name) return;
  if (!out.has(kind)) out.set(kind, []);
  const names = out.get(kind);
  if (!names.includes(name)) names.push(name);
}

function cascadeKindForAllowlistName(name) {
  const n = String(name || '').trim();
  if (!n) return '';
  if (TOOL_MAP[n]?.kind) return TOOL_MAP[n].kind;
  return CASCADE_STEP[n] ? n : '';
}

function nativeAllowlistNameOverrides() {
  const out = new Map();
  const raw = String(process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_ALLOWLIST_NAMES || '').trim();
  if (!raw) return out;
  for (const part of raw.split(',')) {
    const [left, ...rest] = part.split(':');
    const key = String(left || '').trim();
    const value = rest.join(':').trim();
    if (!key || !value) continue;
    out.set(key, value);
  }
  return out;
}

export function nativeAllowlistNameForTool(toolName) {
  const name = String(toolName || '').trim();
  const entry = TOOL_MAP[name];
  if (!entry) return '';
  const overrides = nativeAllowlistNameOverrides();
  if (overrides.has(name)) return overrides.get(name);
  if (overrides.has(entry.kind)) return overrides.get(entry.kind);
  if (entry.kind === 'view_file') return 'read_file';
  return entry.kind;
}

// ─── Forward: build cascade trajectory step proto ───────────────────
//
// These produce raw protobuf bytes that drop straight into
// CortexTrajectoryStep oneof — caller wraps them with the trajectory step
// envelope (type, status, etc) via buildAdditionalStep below.

function buildViewFileBody(args) {
  const parts = [];
  if (args.absolute_path_uri) parts.push(writeStringField(1, args.absolute_path_uri));
  if (args.offset) parts.push(writeVarintField(11, args.offset));
  if (args.limit)  parts.push(writeVarintField(12, args.limit));
  if (typeof args.content === 'string') parts.push(writeStringField(4, args.content));
  return Buffer.concat(parts);
}

function buildRunCommandBody(args) {
  const parts = [];
  if (args.command_line) parts.push(writeStringField(23, args.command_line));
  if (args.cwd) parts.push(writeStringField(2, args.cwd));
  if (args.blocking) parts.push(writeBoolField(11, true));
  if (typeof args.stdout === 'string') parts.push(writeStringField(4, args.stdout));
  if (typeof args.stderr === 'string') parts.push(writeStringField(5, args.stderr));
  if (Number.isFinite(args.exit_code)) parts.push(writeVarintField(6, args.exit_code));
  // combined_output (field 21) — RunCommandOutput { full=1 }. The planner
  // reads this when reasoning about command results, so we mirror stdout
  // there in addition to the legacy stdout field.
  if (typeof args.full_output === 'string') {
    const inner = writeStringField(1, args.full_output);
    parts.push(writeMessageField(21, inner));
  }
  return Buffer.concat(parts);
}

function buildGrepSearchV2Body(args) {
  const parts = [];
  if (args.pattern) parts.push(writeStringField(2, args.pattern));
  if (args.path) parts.push(writeStringField(3, args.path));
  if (args.glob) parts.push(writeStringField(4, args.glob));
  if (args.output_mode) parts.push(writeStringField(5, args.output_mode));
  if (args.case_insensitive) parts.push(writeBoolField(10, true));
  if (args.multiline) parts.push(writeBoolField(13, true));
  if (args.type) parts.push(writeStringField(11, args.type));
  if (args.head_limit) parts.push(writeVarintField(12, args.head_limit));
  if (args.lines_after) parts.push(writeVarintField(6, args.lines_after));
  if (args.lines_before) parts.push(writeVarintField(7, args.lines_before));
  if (args.lines_both) parts.push(writeVarintField(8, args.lines_both));
  if (typeof args.raw_output === 'string') parts.push(writeStringField(15, args.raw_output));
  return Buffer.concat(parts);
}

function buildFindBody(args) {
  const parts = [];
  if (args.search_directory) parts.push(writeStringField(10, args.search_directory));
  if (args.pattern) parts.push(writeStringField(1, args.pattern));
  if (typeof args.raw_output === 'string') parts.push(writeStringField(11, args.raw_output));
  return Buffer.concat(parts);
}

function buildListDirectoryBody(args) {
  const parts = [];
  if (args.directory_path_uri) parts.push(writeStringField(1, args.directory_path_uri));
  // children (field 2, repeated string) — populated when emitting a fake
  // "we already listed this dir, here are the names" step on tool_result.
  if (Array.isArray(args.children)) {
    for (const c of args.children) parts.push(writeStringField(2, String(c)));
  }
  return Buffer.concat(parts);
}

function buildWriteToFileBody(args) {
  const parts = [];
  if (args.target_file_uri) parts.push(writeStringField(1, args.target_file_uri));
  if (Array.isArray(args.code_content)) {
    for (const line of args.code_content) parts.push(writeStringField(2, String(line)));
  }
  if (args.file_created) parts.push(writeBoolField(4, true));
  return Buffer.concat(parts);
}

// v2.0.70 — propose_code (CortexStepProposeCode = field 32 oneof).
// Body schema:
//   ActionSpec action_spec       = 1   message
//   ActionResult action_result   = 2   message
//   string code_instruction      = 3
//   string markdown_language     = 4
//
// ActionSpec.command = 1 → ActionSpecCommand:
//   string instruction              = 1
//   repeated ReplacementChunk chunks = 9
//   bool is_edit                     = 2
//   oneof target → file = 4 (PathScopeItem) using absolute_uri = 5
//
// ReplacementChunk:
//   string target_content      = 1
//   string replacement_content = 2
//   bool allow_multiple        = 3
function buildProposeCodeBody(args) {
  const parts = [];
  // action_spec.command(1).{...}
  const cmdParts = [];
  if (args.instruction) cmdParts.push(writeStringField(1, args.instruction));
  cmdParts.push(writeBoolField(2, true)); // is_edit
  if (Array.isArray(args.replacement_chunks)) {
    for (const c of args.replacement_chunks) {
      const chunkParts = [];
      if (typeof c.target === 'string') chunkParts.push(writeStringField(1, c.target));
      if (typeof c.replacement === 'string') chunkParts.push(writeStringField(2, c.replacement));
      if (c.allow_multiple) chunkParts.push(writeBoolField(3, true));
      cmdParts.push(writeMessageField(9, Buffer.concat(chunkParts)));
    }
  }
  if (args.target_file_uri) {
    // PathScopeItem { absolute_uri = 5 }
    const psi = writeStringField(5, args.target_file_uri);
    cmdParts.push(writeMessageField(4, psi)); // ActionSpecCommand.target.file = 4
  }
  const command = Buffer.concat(cmdParts);
  // ActionSpec.command oneof at field 1
  const actionSpec = writeMessageField(1, command);
  parts.push(writeMessageField(1, actionSpec)); // CortexStepProposeCode.action_spec = 1
  if (args.instruction) parts.push(writeStringField(3, args.instruction));
  return Buffer.concat(parts);
}

// v2.0.70 — search_web (CortexStepSearchWeb = field 42 oneof).
//   query  = 1 string
//   domain = 3 string
//   summary = 5 string  (server-filled; we mirror it on observation injection)
function buildSearchWebBody(args) {
  const parts = [];
  if (args.query) parts.push(writeStringField(1, args.query));
  if (args.domain) parts.push(writeStringField(3, args.domain));
  if (typeof args.summary === 'string') parts.push(writeStringField(5, args.summary));
  return Buffer.concat(parts);
}

// v2.0.93 — read_url_content (CortexStepReadUrlContent = field 40 oneof).
//   url           = 1 string
//   web_document  = 2 KnowledgeBaseItem
//   resolved_url  = 3 string
//   latency_ms    = 4 uint64
//   user_rejected = 6 bool
//   auto_decision = 7 enum
//
// KnowledgeBaseItem fields confirmed from the official Windsurf 2.3.15
// generated client: text=2, url=3, title=4, chunks=6, summary=7.
function buildKnowledgeBaseItem(args = {}) {
  const text = typeof args.text === 'string'
    ? args.text
    : (typeof args.content === 'string' ? args.content : '');
  const summary = typeof args.summary === 'string' ? args.summary : '';
  const url = typeof args.url === 'string' ? args.url : '';
  const title = typeof args.title === 'string' ? args.title : '';
  const parts = [];
  if (text) parts.push(writeStringField(2, text));
  if (url) parts.push(writeStringField(3, url));
  if (title) parts.push(writeStringField(4, title));
  if (summary) parts.push(writeStringField(7, summary));
  return Buffer.concat(parts);
}

function buildReadUrlContentBody(args) {
  const parts = [];
  if (args.url) parts.push(writeStringField(1, args.url));
  const doc = buildKnowledgeBaseItem({
    text: args.text || args.content || args.summary || '',
    summary: args.summary || '',
    url: args.url || args.resolved_url || '',
    title: args.title || '',
  });
  if (doc.length) parts.push(writeMessageField(2, doc));
  if (args.resolved_url) parts.push(writeStringField(3, args.resolved_url));
  if (args.latency_ms != null) parts.push(writeVarintField(4, Number(args.latency_ms) || 0));
  if (args.user_rejected) parts.push(writeBoolField(6, true));
  if (args.auto_run_decision != null) parts.push(writeVarintField(7, Number(args.auto_run_decision) || 0));
  return Buffer.concat(parts);
}

const STEP_BODY_BUILDER = {
  view_file:         buildViewFileBody,
  run_command:       buildRunCommandBody,
  grep_search_v2:    buildGrepSearchV2Body,
  grep_search:       buildGrepSearchV2Body,
  find:              buildFindBody,
  list_directory:    buildListDirectoryBody,
  write_to_file:     buildWriteToFileBody,
  propose_code:      buildProposeCodeBody,
  search_web:        buildSearchWebBody,
  read_url_content:  buildReadUrlContentBody,
};

/**
 * Build a CortexTrajectoryStep proto carrying a completed cascade-side step.
 * Used to inject "the caller's tool result, expressed as if the planner
 * already ran the equivalent cascade tool" into
 * SendUserCascadeMessageRequest.additional_steps[9]. The planner sees a
 * trajectory that already contains the answer and continues reasoning from
 * there instead of asking again.
 *
 * `observed` is REQUIRED (V-13 / KH-034). A step may only be stamped DONE
 * when the caller reported a result for it; without that gate an unobserved
 * call was encoded as "ran successfully, empty output", which the planner
 * then treated as real history.
 *
 * Returns null if `kind` has no encoder or no observation was supplied.
 */
export function buildAdditionalStep(kind, args, { observed = false } = {}) {
  const meta = CASCADE_STEP[kind];
  const builder = STEP_BODY_BUILDER[kind];
  if (!meta || !builder || observed !== true) return null;
  const body = builder(args || {});
  // CortexTrajectoryStep envelope: type=1, status=4, oneof step body
  return Buffer.concat([
    writeVarintField(1, meta.typeEnum),
    writeVarintField(4, CASCADE_STEP_STATUS_DONE),
    writeMessageField(meta.oneofField, body),
  ]);
}

// ─── Reverse: parse cascade trajectory step → OpenAI tool_call ────────
//
// CortexTrajectoryStep parser shared with windsurf.js parseTrajectorySteps.
// This module's variant focuses on the args side — given an already-parsed
// step (with the oneof body extracted), produce a {kind, args, observation}
// triple. windsurf.js handles the integer-tag pass; here we only decode the
// per-kind field schema.

function decodeViewFileStep(buf) {
  const f = parseFields(buf);
  return {
    absolute_path_uri: getField(f, 1, 2)?.value?.toString('utf8') || '',
    start_line: Number(getField(f, 2, 0)?.value || 0),
    end_line: Number(getField(f, 3, 0)?.value || 0),
    offset: Number(getField(f, 11, 0)?.value || 0),
    limit: Number(getField(f, 12, 0)?.value || 0),
    content: getField(f, 4, 2)?.value?.toString('utf8') || '',
    raw_content: getField(f, 9, 2)?.value?.toString('utf8') || '',
  };
}

function decodeRunCommandStep(buf) {
  const f = parseFields(buf);
  const decoded = {
    command_line: getField(f, 23, 2)?.value?.toString('utf8')
                || getField(f, 1, 2)?.value?.toString('utf8') || '',
    proposed_command_line: getField(f, 25, 2)?.value?.toString('utf8') || '',
    cwd: getField(f, 2, 2)?.value?.toString('utf8') || '',
    stdout: getField(f, 4, 2)?.value?.toString('utf8') || '',
    stderr: getField(f, 5, 2)?.value?.toString('utf8') || '',
    exit_code: getField(f, 6, 0)?.value,
  };
  // combined_output (RunCommandOutput { full=1 })
  const combined = getField(f, 21, 2);
  if (combined) {
    const c = parseFields(combined.value);
    const full = getField(c, 1, 2)?.value?.toString('utf8') || '';
    if (full) decoded.full_output = full;
  }
  return decoded;
}

function decodeGrepSearchV2Step(buf) {
  const f = parseFields(buf);
  return {
    pattern: getField(f, 2, 2)?.value?.toString('utf8') || '',
    path: getField(f, 3, 2)?.value?.toString('utf8') || '',
    glob: getField(f, 4, 2)?.value?.toString('utf8') || '',
    output_mode: getField(f, 5, 2)?.value?.toString('utf8') || '',
    lines_after: Number(getField(f, 6, 0)?.value || 0),
    lines_before: Number(getField(f, 7, 0)?.value || 0),
    lines_both: Number(getField(f, 8, 0)?.value || 0),
    case_insensitive: !!getField(f, 10, 0)?.value,
    multiline: !!getField(f, 13, 0)?.value,
    type: getField(f, 11, 2)?.value?.toString('utf8') || '',
    head_limit: Number(getField(f, 12, 0)?.value || 0),
    raw_output: getField(f, 15, 2)?.value?.toString('utf8') || '',
  };
}

function decodeFindStep(buf) {
  const f = parseFields(buf);
  return {
    search_directory: getField(f, 10, 2)?.value?.toString('utf8') || '',
    pattern: getField(f, 1, 2)?.value?.toString('utf8') || '',
    raw_output: getField(f, 11, 2)?.value?.toString('utf8') || '',
  };
}

function decodeListDirectoryStep(buf) {
  const f = parseFields(buf);
  return {
    directory_path_uri: getField(f, 1, 2)?.value?.toString('utf8') || '',
    children: getAllFields(f, 2)
      .filter(x => x.wireType === 2)
      .map(x => x.value.toString('utf8')),
  };
}

function decodeWriteToFileStep(buf) {
  const f = parseFields(buf);
  return {
    target_file_uri: getField(f, 1, 2)?.value?.toString('utf8') || '',
    code_content: getAllFields(f, 2)
      .filter(x => x.wireType === 2)
      .map(x => x.value.toString('utf8')),
    file_created: !!getField(f, 4, 0)?.value,
  };
}

// v2.0.70 — decode propose_code: pull file_uri + replacement_chunks
// out of nested ActionSpec.command for round-trip back to Edit args.
function decodeProposeCodeStep(buf) {
  const f = parseFields(buf);
  const actionSpec = getField(f, 1, 2);
  const out = { target_file_uri: '', replacement_chunks: [], instruction: '' };
  if (!actionSpec) return out;
  const asFields = parseFields(actionSpec.value);
  const command = getField(asFields, 1, 2);
  if (!command) return out;
  const cmdFields = parseFields(command.value);
  const instr = getField(cmdFields, 1, 2);
  if (instr) out.instruction = instr.value.toString('utf8');
  const fileTarget = getField(cmdFields, 4, 2);
  if (fileTarget) {
    const psi = parseFields(fileTarget.value);
    const uri = getField(psi, 5, 2)?.value?.toString('utf8')
             || getField(psi, 1, 2)?.value?.toString('utf8') || '';
    out.target_file_uri = uri;
  }
  for (const chunkField of getAllFields(cmdFields, 9)) {
    if (chunkField.wireType !== 2) continue;
    const cp = parseFields(chunkField.value);
    out.replacement_chunks.push({
      target: getField(cp, 1, 2)?.value?.toString('utf8') || '',
      replacement: getField(cp, 2, 2)?.value?.toString('utf8') || '',
      allow_multiple: !!getField(cp, 3, 0)?.value,
    });
  }
  return out;
}

function decodeSearchWebStep(buf) {
  const f = parseFields(buf);
  return {
    query: getField(f, 1, 2)?.value?.toString('utf8') || '',
    domain: getField(f, 3, 2)?.value?.toString('utf8') || '',
    summary: getField(f, 5, 2)?.value?.toString('utf8') || '',
  };
}

function decodeMarkdownChunk(buf) {
  const f = parseFields(buf);
  return {
    text: getField(f, 2, 2)?.value?.toString('utf8') || '',
  };
}

function decodeKnowledgeBaseChunk(buf) {
  const f = parseFields(buf);
  const markdown = getField(f, 3, 2);
  return {
    text: getField(f, 1, 2)?.value?.toString('utf8') || '',
    markdown_text: markdown ? decodeMarkdownChunk(markdown.value).text : '',
  };
}

function decodeKnowledgeBaseItem(buf) {
  const f = parseFields(buf);
  return {
    text: getField(f, 2, 2)?.value?.toString('utf8') || '',
    url: getField(f, 3, 2)?.value?.toString('utf8') || '',
    title: getField(f, 4, 2)?.value?.toString('utf8') || '',
    chunks: getAllFields(f, 6)
      .filter(x => x.wireType === 2)
      .map(x => decodeKnowledgeBaseChunk(x.value)),
    summary: getField(f, 7, 2)?.value?.toString('utf8') || '',
  };
}

function knowledgeBaseItemText(doc = {}) {
  if (doc.text) return doc.text;
  const chunkText = (doc.chunks || [])
    .map(c => c.text || c.markdown_text || '')
    .filter(Boolean)
    .join('\n');
  return chunkText || doc.summary || '';
}

function decodeReadUrlContentStep(buf) {
  const f = parseFields(buf);
  const webDocumentField = getField(f, 2, 2);
  const web_document = webDocumentField ? decodeKnowledgeBaseItem(webDocumentField.value) : null;
  const legacy_summary = getField(f, 5, 2)?.value?.toString('utf8') || '';
  const content = knowledgeBaseItemText(web_document || {}) || legacy_summary;
  return {
    url: getField(f, 1, 2)?.value?.toString('utf8') || '',
    web_document,
    resolved_url: getField(f, 3, 2)?.value?.toString('utf8') || '',
    latency_ms: Number(getField(f, 4, 0)?.value || 0),
    legacy_summary,
    content,
    summary: web_document?.summary || legacy_summary,
    user_rejected: !!getField(f, 6, 0)?.value,
    auto_run_decision: Number(getField(f, 7, 0)?.value || 0),
  };
}

const STEP_BODY_DECODER = {
  view_file:         decodeViewFileStep,
  run_command:       decodeRunCommandStep,
  grep_search_v2:    decodeGrepSearchV2Step,
  grep_search:       decodeGrepSearchV2Step,
  find:              decodeFindStep,
  list_directory:    decodeListDirectoryStep,
  write_to_file:     decodeWriteToFileStep,
  propose_code:      decodeProposeCodeStep,
  search_web:        decodeSearchWebStep,
  read_url_content:  decodeReadUrlContentStep,
};

/**
 * Given a CortexTrajectoryStep envelope (already parsed) and the caller's
 * declared tools[], emit an OpenAI-shaped tool_call:
 *
 *   { id, name, argumentsJson, observation }
 *
 * - `id` is synthesised from cascadeId + step index by the caller of this
 *   function (we don't have those handles here).
 * - `name` is the caller-visible OpenAI tool name (Read/Bash/Grep/...) —
 *   resolved via buildReverseLookup. Falls back to the cascade kind name
 *   when the caller didn't declare a matching tool, which lets the proxy
 *   STILL surface trajectory tool calls for diagnostic purposes.
 * - `argumentsJson` is `JSON.stringify(reverse(decoded))`.
 * - `observation` is the part of the step the planner already filled in
 *   (file content, command stdout, etc) — the proxy DROPS this when
 *   forwarding to the caller (the caller will run their own version) but
 *   it's exposed here so callers that want to short-circuit on cached
 *   results can detect them.
 */
export function decodeCascadeStepToToolCall(stepFields, kind, callerLookup) {
  const decoder = STEP_BODY_DECODER[kind];
  const meta = CASCADE_STEP[kind];
  if (!decoder || !meta) return null;
  const oneof = getField(stepFields, meta.oneofField, 2);
  if (!oneof) return null;
  const decoded = decoder(oneof.value);
  const candidates = callerLookup?.get(kind) || [];
  // Pick the caller's preferred name. If they declared multiple tools that
  // map onto this kind (rare — e.g., both Read and read_file) we use the
  // first one in declaration order, which matches what they'd get from
  // calling the same tool twice in their own code.
  const callerName = candidates[0] || kind;
  const reverseFn = TOOL_MAP[callerName]?.reverse || identityArgs;
  let args;
  try {
    args = reverseFn(decoded);
  } catch {
    args = decoded;
  }
  return {
    name: callerName,
    arguments: args,
    cascade_kind: kind,
    observation: decoded,
  };
}

/**
 * Claude sometimes emits its provider-native tool syntax while Cascade is in
 * native bridge mode:
 *
 *   <function_calls>
 *   <invoke name="read_file">
 *   <parameter name="path">README.md</parameter>
 *   </invoke>
 *
 * This is not prompt emulation. It is a native-format fallback for models that
 * did not surface a CortexTrajectoryStep but still produced a structured tool
 * proposal. Convert it back to the caller's declared tool name.
 */
export function parseNativeFunctionCallsFromText(text, callerLookup = new Map()) {
  if (typeof text !== 'string' || !/<function_calls\b/i.test(text)) {
    return { text: text || '', toolCalls: [] };
  }
  const calls = [];
  const keepOutsideBlocks = [];
  let lastBlockEnd = 0;
  const blockRe = /<function_calls\b[^>]*>([\s\S]*?)<\/function_calls>/gi;
  const invokeRe = /<invoke\b([^>]*)>([\s\S]*?)<\/invoke>/gi;
  let block;
  while ((block = blockRe.exec(text)) !== null) {
    keepOutsideBlocks.push(text.slice(lastBlockEnd, block.index));
    lastBlockEnd = block.index + block[0].length;
    invokeRe.lastIndex = 0;
    let match;
    while ((match = invokeRe.exec(block[1])) !== null) {
      const nameAttr = match[1].match(/\bname\s*=\s*"([^"]+)"/i)
        || match[1].match(/\bname\s*=\s*'([^']+)'/i);
      const rawName = nameAttr ? unescapeXmlText(nameAttr[1]).trim() : '';
      const entry = TOOL_MAP[rawName];
      if (!rawName || !entry) continue;
      const params = {};
      const paramRe = /<parameter\b([^>]*)>([\s\S]*?)<\/parameter>/gi;
      let pm;
      while ((pm = paramRe.exec(match[2])) !== null) {
        const paramNameAttr = pm[1].match(/\bname\s*=\s*"([^"]+)"/i)
          || pm[1].match(/\bname\s*=\s*'([^']+)'/i);
        const paramName = paramNameAttr ? unescapeXmlText(paramNameAttr[1]).trim() : '';
        if (!paramName) continue;
        const rawValue = unescapeXmlText(pm[2].trim());
        const parsed = safeJsonParse(rawValue);
        params[paramName] = Object.keys(parsed).length || /^[\[{]/.test(rawValue.trim())
          ? parsed
          : rawValue;
      }
      const cascadeKind = entry.kind;
      const candidates = callerLookup?.get(cascadeKind) || [];
      const callerName = candidates[0];
      if (!callerName) continue;
      const reverseFn = TOOL_MAP[callerName]?.reverse || identityArgs;
      let openaiArgs;
      try { openaiArgs = reverseFn(entry.forward(params)); }
      catch { openaiArgs = params; }
      calls.push({
        id: `call_native_${calls.length}_${Date.now().toString(36)}`,
        name: callerName,
        argumentsJson: JSON.stringify(openaiArgs ?? {}),
      });
    }
  }
  const trailing = text.slice(lastBlockEnd);
  const danglingOpen = trailing.search(/<function_calls\b/i);
  keepOutsideBlocks.push(danglingOpen === -1 ? trailing : trailing.slice(0, danglingOpen));
  return { text: keepOutsideBlocks.join('').trim(), toolCalls: calls };
}

/**
 * Streaming guard for Claude provider-native <function_calls> blocks in native
 * bridge mode. It withholds any partial block from content deltas until a
 * closing </function_calls> arrives, then converts it to OpenAI tool_calls.
 */
const NATIVE_XML_BODY_MAX = 65_536;

export class NativeFunctionCallStreamParser {
  constructor(callerLookup = new Map()) {
    this.callerLookup = callerLookup;
    this.buffer = '';
  }

  feed(delta) {
    if (!delta) return { text: '', toolCalls: [] };
    this.buffer += String(delta);
    return this._drain(false);
  }

  flush() {
    return this._drain(true);
  }

  _drain(flush) {
    const out = [];
    const toolCalls = [];
    const openRe = /<function_calls\b/i;
    while (this.buffer) {
      const open = this.buffer.search(openRe);
      if (open === -1) {
        const hold = partialFunctionCallsPrefixLength(this.buffer);
        if (!flush && hold > 0) {
          out.push(this.buffer.slice(0, this.buffer.length - hold));
          this.buffer = this.buffer.slice(this.buffer.length - hold);
          break;
        }
        out.push(this.buffer);
        this.buffer = '';
        break;
      }

      out.push(this.buffer.slice(0, open));
      const tail = this.buffer.slice(open);
      const closeMatch = tail.match(/<\/function_calls>/i);
      if (!closeMatch) {
        if (flush || tail.length > NATIVE_XML_BODY_MAX) {
          // Incomplete native XML is dropped (existing flush policy). The
          // cap stops unbounded RSS if the close tag never arrives.
          this.buffer = '';
        } else {
          this.buffer = tail;
        }
        break;
      }

      const end = open + closeMatch.index + closeMatch[0].length;
      const block = this.buffer.slice(open, end);
      const parsed = parseNativeFunctionCallsFromText(block, this.callerLookup);
      out.push(parsed.text || '');
      toolCalls.push(...(parsed.toolCalls || []));
      this.buffer = this.buffer.slice(end);
    }
    return { text: out.join(''), toolCalls };
  }
}

function partialFunctionCallsPrefixLength(text) {
  const lit = '<function_calls';
  const lower = String(text || '').toLowerCase();
  const max = Math.min(lit.length - 1, lower.length);
  for (let n = max; n > 0; n--) {
    if (lower.endsWith(lit.slice(0, n))) return n;
  }
  return 0;
}

// ─── Inject caller's tool history as additional_steps ───────────────
//
// When the caller's prior turns include role:"tool" messages (responses to
// tool_calls the assistant made), we want the planner to see a trajectory
// that already contains those steps with their results. This lets the
// planner reason from the post-tool state instead of re-asking.

/**
 * Translate the caller's assistant/tool turns into CortexTrajectoryStep
 * buffers plus the message list the emulation path should keep.
 *
 * For each tool_call whose name maps onto a cascade kind we encode the call
 * args AS IF the planner had emitted them, and overlay the tool result onto
 * the step (e.g. view_file.content = result_string for Read). Unmapped
 * calls (and calls whose forward translation is lossy) stay in
 * `emulationMessages` so the existing <tool_result> emulation still carries
 * them — the bridge must not swallow history it cannot encode.
 *
 * `reuse: true` means the caller is resuming a cascade that already holds
 * every observation step from earlier turns. Only the newest pending tool
 * round can be new, so earlier rounds are not re-injected (V-13 / KH-034).
 *
 * Returns { steps: Buffer[], deltaStart: number, emulationMessages: object[] }.
 * `deltaStart` indexes the first step of the newest pending tool round, so a
 * caller holding the full list can slice it for a reused cascade instead of
 * encoding the history again. With `reuse: true`, `steps` is already that
 * slice and `deltaStart` is 0.
 */
export function buildNativeToolHistory(messages, { reuse = false } = {}) {
  if (!Array.isArray(messages) || !messages.length) {
    return { steps: [], emulationMessages: [] };
  }

  const resultByCallId = new Map();
  for (const m of messages) {
    if (m?.role !== 'tool' || m.tool_call_id == null) continue;
    resultByCallId.set(String(m.tool_call_id), m);
  }

  // A mapped call with a lossless forward translation belongs to the bridge
  // for this request: it becomes a completed step when observed, and it is
  // kept out of the text history either way. Everything else is emulation's.
  const bridgeOwnedIds = new Set();
  const candidates = [];
  for (let i = 0; i < messages.length; i++) {
    const m = messages[i];
    if (m?.role !== 'assistant' || !Array.isArray(m.tool_calls)) continue;
    for (const tc of m.tool_calls) {
      const entry = TOOL_MAP[tc?.function?.name];
      if (!entry) continue;
      const args = safeJsonParse(tc.function?.arguments);
      let cascadeArgs;
      try { cascadeArgs = entry.forward(args); } catch { continue; }
      // v2.0.70 — apply_patch returns a sentinel from forward()
      // because we can't lossless-encode multi-file patches into a
      // single cascade step. Skip and let partition fallback to
      // emulation toolPreamble.
      if (cascadeArgs?.__apply_patch_unmappable) continue;
      if (tc.id != null) bridgeOwnedIds.add(String(tc.id));
      const resultMessage = tc.id != null ? resultByCallId.get(String(tc.id)) : null;
      candidates.push({
        messageIndex: i,
        entry,
        cascadeArgs,
        resultMessage,
        observation: resultMessage ? toolResultContent(resultMessage) : undefined,
      });
    }
  }

  const fromIndex = reuse ? trailingPendingRoundStart(messages) : 0;
  // Index of the first step that belongs to the newest pending tool round.
  // Callers that already hold the full list can slice it instead of encoding
  // the history a second time when a cascade is reused.
  let deltaStart = reuse ? 0 : -1;
  const steps = [];
  if (!reuse || fromIndex >= 0) {
    const pendingStart = reuse ? fromIndex : trailingPendingRoundStart(messages);
    for (const { messageIndex, entry, cascadeArgs, observation, resultMessage } of candidates) {
      if (!reuse && deltaStart < 0 && pendingStart >= 0 && messageIndex >= pendingStart) {
        deltaStart = steps.length;
      }
      if (messageIndex < fromIndex) continue;
      // Require an observation before completion: an unanswered proposal is
      // not a DONE step (V-13).
      if (typeof observation !== 'string') continue;
      // Overlay the result onto the cascade step's "this is what came
      // back" field. Per-kind because the result field varies:
      //   view_file       → content (4)
      //   run_command     → combined_output (21) — the authoritative result field
      //   grep_search_v2  → raw_output (15)
      //   find            → raw_output (11)
      //   list_directory  → children (2, repeated)
      //   write_to_file   → file_created (4) ← bool, ignore content
      //   search_web      → summary (5)
      //   read_url_content → web_document.text (2.2)
      //   propose_code    → no native result field (proxy passes back
      //                    through emulation toolPreamble result text)
      if (entry.kind === 'view_file') cascadeArgs.content = observation;
      else if (entry.kind === 'run_command') {
        // One authoritative output field: the planner reads combined_output;
        // writing stdout as well duplicated every observation.
        cascadeArgs.full_output = observation;
        // Never fabricate success: a failed command keeps its exit status
        // (and a signalled failure without a numeric code is non-zero).
        const status = parseObservationExitStatus(observation, resultMessage);
        if (status) cascadeArgs.exit_code = status.exitCode;
      } else if (entry.kind === 'grep_search_v2' || entry.kind === 'grep_search') {
        cascadeArgs.raw_output = observation;
      } else if (entry.kind === 'find') {
        cascadeArgs.raw_output = observation;
      } else if (entry.kind === 'list_directory') {
        cascadeArgs.children = observation
          .split(/\r?\n/)
          .map(s => s.trim())
          .filter(Boolean);
      } else if (entry.kind === 'search_web') {
        cascadeArgs.summary = observation;
      } else if (entry.kind === 'read_url_content') {
        cascadeArgs.content = observation;
      }
      const buf = buildAdditionalStep(entry.kind, cascadeArgs, { observed: true });
      if (buf) steps.push(buf);
    }
  }
  if (deltaStart < 0) deltaStart = steps.length;

  return { steps, deltaStart, emulationMessages: buildEmulationHistory(messages, bridgeOwnedIds) };
}

/**
 * Same step list as buildNativeToolHistory().steps, kept as the narrow API
 * for callers that only need the protos.
 */
export function buildAdditionalStepsFromHistory(messages, options = {}) {
  return buildNativeToolHistory(messages, options).steps;
}

function toolResultContent(m) {
  if (typeof m?.content === 'string') return m.content;
  if (Array.isArray(m?.content)) {
    return m.content.filter(p => typeof p?.text === 'string').map(p => p.text).join('\n');
  }
  return JSON.stringify(m?.content ?? '');
}

// proto3 reads an absent exit_code as 0 (success). A result the caller
// flagged as failed therefore has to carry a non-zero code even when the
// text has no numeric status.
const EXIT_CODE_UNKNOWN_FAILURE = 1;

/**
 * Read the exit status the caller reported for a shell result. The tool
 * result is opaque client-owned text, so recognize the shapes the common
 * dialects emit instead of assuming success: plain-text markers, codex-style
 * JSON metadata, and Anthropic's is_error flag / `[tool error]` prefix.
 *
 * Returns { exitCode } or null when the observation carries no status.
 */
function parseObservationExitStatus(observation, message) {
  const text = typeof observation === 'string' ? observation : '';
  const signalledFailure = message?.is_error === true || /^\s*\[tool error\]/i.test(text);
  const accept = (value) => Number.isSafeInteger(value) && value >= 0 && value <= 0x7fffffff;
  const json = safeJsonParse(text);
  for (const value of [
    json.exit_code, json.exitCode,
    json.metadata?.exit_code, json.metadata?.exitCode,
  ]) {
    if (accept(value)) return { exitCode: value };
  }
  const patterns = [
    /\bexit\s+(?:code|status)\s*[:=]?\s*(\d{1,10})\b/i,
    /\bexited\s+with\s+(?:code|status)\s*[:=]?\s*(\d{1,10})\b/i,
    /\[(?:process\s+)?exit(?:ed)?\s+(?:code|status)?\s*[:=]?\s*(\d{1,10})\]/i,
  ];
  for (const pattern of patterns) {
    const match = text.match(pattern);
    if (!match) continue;
    const value = Number(match[1]);
    if (accept(value)) return { exitCode: value };
  }
  return signalledFailure ? { exitCode: EXIT_CODE_UNKNOWN_FAILURE } : null;
}

/**
 * Index of the last assistant tool_calls message when every message after it
 * is a tool result — the only round a reused cascade can be missing. Returns
 * -1 when the newest turn is not a pending tool round (or there is none).
 */
function trailingPendingRoundStart(messages) {
  let start = -1;
  for (let i = messages.length - 1; i >= 0; i--) {
    const m = messages[i];
    if (m?.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
      start = i;
      break;
    }
  }
  if (start < 0 || start === messages.length - 1) return -1;
  for (let i = start + 1; i < messages.length; i++) {
    if (messages[i]?.role !== 'tool') return -1;
  }
  return start;
}

/**
 * Drop the pairs the bridge encoded (or owns but could not observe) from the
 * message list the emulation path will fold into text. Unmapped calls,
 * lossy translations and orphan tool results stay, so the planner keeps that
 * history instead of losing it between the two paths.
 */
function buildEmulationHistory(messages, bridgeOwnedIds) {
  const out = [];
  for (const m of messages) {
    if (m?.role === 'tool') {
      if (m.tool_call_id != null && bridgeOwnedIds.has(String(m.tool_call_id))) continue;
      out.push(m);
      continue;
    }
    if (m?.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
      const retained = m.tool_calls.filter(tc => !(tc?.id != null && bridgeOwnedIds.has(String(tc.id))));
      if (retained.length === m.tool_calls.length) { out.push(m); continue; }
      if (retained.length) { out.push({ ...m, tool_calls: retained }); continue; }
      const { tool_calls: _consumed, ...rest } = m;
      if (rest.content != null && rest.content !== '') out.push(rest);
      continue;
    }
    out.push(m);
  }
  return out;
}

// ─── Activation gate ────────────────────────────────────────────────

/**
 * Return true when the native bridge should be used for this request.
 *
 * v2.0.66 (#115 partition mode): the gate switched from "every tool must
 * be mapped" (canMapAllTools) to "at least one tool is mapped"
 * (partitionTools.hasAny). Real-world clients — codex CLI 0.128 declares
 * 11 tools, only 1 (shell_command) maps cleanly — never pass the all-or-
 * nothing bar, which is why v2.0.65 deployed but never fired in
 * production. With partition mode, mapped tools route through
 * trajectory-step injection while unmapped tools keep the emulation
 * toolPreamble path; both coexist in the same request.
 *
 * v2.0.70 (#115 root-cause fix): GPT family REMOVED from auto-on. Real
 * end-to-end probe on v2.0.69 with a single shell_command tool + GPT-5.5
 * showed `markers=none / 0 tool_calls` and the model fabricated a fake
 * timestamp output — cascade DEFAULT planner_mode describes
 * `run_command` using cascade's internal trajectory grammar, which
 * GPT's training has never seen.
 *
 * v2.0.75 (#124 zhqsuo critical regression). v2.0.70 ALSO turned the
 * auto-on the OTHER way for Anthropic Claude — premise was "Claude
 * speaks cascade-style tool_use natively so it'll execute well in the
 * planner-mode path." Real-world behaviour was the opposite: when
 * Claude Code (or any client whose tools[] models LOCAL filesystem ops
 * — Read / Edit / Bash) hits this path, the cascade planner runs the
 * tool inside Windsurf's REMOTE workspace sandbox
 * (`/home/user/projects/workspace-devinxse`), not the user's machine.
 * The user's files don't exist there, so `run_command` / `view_file`
 * hang at lastStatus=2 (ACTIVE) until warm stall fires — every Claude
 * tool call permanently stuck for 6+ minutes before erroring.
 *
 * The native bridge is only correct for clients whose tool inventory
 * models REMOTE work (e.g. a self-contained agent that wants the
 * proxy's sandbox to be the execution environment). Claude Code, Cline,
 * Codex CLI, opencode all expect LOCAL execution. We can't tell from
 * tools[] alone which intent the caller has, and the safe default
 * therefore has to be OFF — opt in via env when the deployer knows the
 * caller wants remote execution.
 *
 * Env knobs:
 *   WINDSURFAPI_NATIVE_TOOL_BRIDGE=1          → force on for any mapped subset
 *   WINDSURFAPI_NATIVE_TOOL_BRIDGE=all_mapped → on only if all tools map
 *   WINDSURFAPI_NATIVE_TOOL_BRIDGE_OFF=1      → force off (highest priority)
 */
function nativeBridgeModeFlags() {
  const mode = String(process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE || '').trim().toLowerCase();
  return {
    mode,
    explicitOn: mode === '1' || mode === 'true' || mode === 'force',
    allMappedOnly: mode === 'all_mapped' || mode === 'all-mapped' || mode === 'mapped_only' || mode === 'mapped-only',
  };
}

function toolNamesFromList(tools) {
  return (Array.isArray(tools) ? tools : [])
    .map(t => t?.function?.name || t?.name || '')
    .filter(Boolean);
}

function nativeBridgeDecision({
  enabled = false,
  reason,
  tools,
  partition,
  useCascade,
  mode,
  explicitOn,
  allMappedOnly,
  modelKey,
  model,
  provider,
  route,
} = {}) {
  const mappedTools = toolNamesFromList(partition?.mapped);
  const unmappedTools = toolNamesFromList(partition?.unmapped);
  return {
    enabled: !!enabled,
    reason: reason || (enabled ? 'native_bridge_enabled' : 'native_bridge_disabled'),
    mode,
    explicitOn: !!explicitOn,
    allMappedOnly: !!allMappedOnly,
    useCascade: !!useCascade,
    modelKey: String(modelKey || ''),
    requestedModel: String(model || ''),
    provider: String(provider || ''),
    route: String(route || ''),
    hasTools: Array.isArray(tools) && tools.length > 0,
    toolCount: Array.isArray(tools) ? tools.length : 0,
    mappedCount: mappedTools.length,
    unmappedCount: unmappedTools.length,
    mappedTools,
    unmappedTools,
    partition,
  };
}

export function getNativeBridgeDecision(tools, {
  modelKey = '',
  model = '',
  provider = '',
  route = '',
  callerKey = '',
  useCascade = true,
} = {}) {
  const list = Array.isArray(tools) ? tools : [];
  const partition = partitionTools(list);
  const { mode, explicitOn, allMappedOnly } = nativeBridgeModeFlags();
  const base = {
    tools: list,
    partition,
    useCascade,
    mode,
    explicitOn,
    allMappedOnly,
    modelKey,
    model,
    provider,
    route,
  };

  if (process.env.WINDSURFAPI_NATIVE_TOOL_BRIDGE_OFF === '1') {
    return nativeBridgeDecision({ ...base, reason: 'native_bridge_off' });
  }
  if (!useCascade) {
    return nativeBridgeDecision({ ...base, reason: 'native_bridge_non_cascade' });
  }
  if (!list.length) {
    return nativeBridgeDecision({ ...base, reason: 'native_bridge_no_tools' });
  }
  if (!explicitOn && !allMappedOnly) {
    return nativeBridgeDecision({ ...base, reason: 'native_bridge_mode_not_enabled' });
  }
  const grayGate = nativeBridgeGrayGateStatus({ modelKey, model, provider, route, callerKey });
  if (!grayGate.ok) {
    return nativeBridgeDecision({ ...base, reason: grayGate.errorType || 'native_bridge_gray_gate_denied' });
  }
  if (!partition.hasAny) {
    return nativeBridgeDecision({ ...base, reason: 'native_bridge_no_mapped_tools' });
  }
  if (allMappedOnly && partition.unmapped.length) {
    return nativeBridgeDecision({ ...base, reason: 'native_bridge_all_mapped_required' });
  }
  return nativeBridgeDecision({ ...base, enabled: true, reason: 'native_bridge_enabled' });
}

export function shouldUseNativeBridge(tools, { modelKey = '', model = '', provider = '', route = '', callerKey = '' } = {}) {
  return getNativeBridgeDecision(tools, {
    modelKey,
    model,
    provider,
    route,
    callerKey,
    useCascade: true,
  }).enabled;
}