{"repo": "bogdanadnan/reasonixenhanced", "files": {"reasonix.example.toml": {"c480afd7570fbb1fb96ff82a0ab117c5df7768e8": {"t": 1785745359.9579132, "n": 9846, "h": "185ea68bf2c719a5", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench; desktop layout style\n# theme = \"light\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprices = { \"deepseek-v4-flash\" = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency = \"$\" }, \"deepseek-v4-pro\" = { cache_hit = 0.003625, input = 0.435, output = 0.87, currency = \"$\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "a029618da9916a84d7289efa722405373a68062a": {"t": 1785750694.6893241, "n": 9846, "h": "185ea68bf2c719a5", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench; desktop layout style\n# theme = \"light\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprices = { \"deepseek-v4-flash\" = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency = \"$\" }, \"deepseek-v4-pro\" = { cache_hit = 0.003625, input = 0.435, output = 0.87, currency = \"$\" } } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "a6aae98f8286ff45cfb9f98682b40f266ff37fd0": {"t": 1785750695.8148978, "n": 9813, "h": "5df69889e5ec03e6", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[desktop]\nlayout_style = \"classic\" # classic|workbench; desktop layout style\n# theme = \"light\" # desktop only: auto|dark|light\n# theme_style = \"graphite\" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "a58532f0217587ec9ca9b73bf05b257442067fd1": {"t": 1785750696.900191, "n": 9589, "h": "4a05bf0b3fb44dd7", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n# sandbox = false # true 使用 QQ 沙箱 API / gateway\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "c376ec76e2ff0836ddb0e346724eb155265bbeb7": {"t": 1785750698.0180554, "n": 9532, "h": "ca6e06a54a98089a", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# PermissionRequest / UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PermissionRequest\": [ { \"match\": \"bash\", \"command\": \"notify-send 'approval needed'\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "f6d8cce7eb7294a4a9e4a5c6492cb0941112e545": {"t": 1785750699.0952213, "n": 9414, "h": "cfa9fb1b512368fe", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# reasoning_language = \"auto\" # visible reasoning text: auto|zh|en\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "040126d224b297e561fecea3c330bcfd02b8aecc": {"t": 1785750700.199653, "n": 9345, "h": "efa5b8ae95c2ca0c", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n# shortcut_layout = \"desktop\" # classic|desktop; compatibility setting; Shift+Tab toggles Plan, Ctrl+Y toggles YOLO\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "28bedc6b7ffe167c6325863a2d3ff0034f7bb7d3": {"t": 1785750701.269344, "n": 9227, "h": "bbc0c625700e3514", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n\n# Bot gateway: multi-channel IM bot for QQ, Feishu, and WeChat.\n# Start with `reasonix bot start --channels qq,feishu,weixin`.\n# All secrets are read from environment variables; never put keys here.\n# [bot]\n# enabled = false\n# model = \"\" # 用于 bot 的模型,空则用 default_model\n# max_steps = 25\n# debounce_ms = 1500 # 消息合并窗口(毫秒)\n#\n# [bot.allowlist]\n# enabled = true\n# allow_all = false # true 会允许所有可触达用户远程触发 bot,仅限受控环境\n# qq_users = []\n# feishu_users = []\n# weixin_users = []\n# qq_groups = []\n# feishu_groups = []\n# weixin_groups = []\n#\n# [bot.qq]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"QQ_BOT_APP_SECRET\"\n#\n# [bot.feishu]\n# enabled = false\n# app_id = \"\"\n# app_secret_env = \"FEISHU_BOT_APP_SECRET\"\n# verification_token = \"\"\n# mode = \"webhook\" # webhook | websocket(websocket 需接官方 SDK 长连接)\n# webhook_port = 8080\n# require_mention = true\n#\n# [bot.weixin]\n# enabled = false\n# account_id = \"default\"\n# token_env = \"WEIXIN_BOT_TOKEN\"\n# api_base = \"https://ilinkai.weixin.qq.com\" # Tencent iLink Bot API base URL\n"}, "0d248f7d21dd88ef397faaac653a6bc9fabf3058": {"t": 1785750702.3718321, "n": 8098, "h": "e30be55feedc9416", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 0 # executor tool-call rounds; 0 = no limit\nplanner_max_steps = 12 # planner read-only tool-call rounds; 0 = no limit\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n"}, "304e1152cddce0b025861b5accc77c52b20aee04": {"t": 1785750703.4628253, "n": 7960, "h": "f246f4a71788b8e9", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek models: high|max; Anthropic: low|medium|high|xhigh|max). DeepSeek\n# models behind a proxy are detected by model name; set reasoning_protocol =\n# \"none\" to disable that or \"openai\" to force ordinary reasoning_effort.\n# Other kinds (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# reasoning_protocol = \"openai\" # auto|deepseek|openai|none\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n"}, "bf0c67db5a3d651413e3234029fc81865caa932a": {"t": 1785750704.5227954, "n": 7732, "h": "7618101373104f4c", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek: high|max; Anthropic: low|medium|high|xhigh|max). Other kinds\n# (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\nbash_timeout_seconds = 120 # foreground safety cap; set 0 for no tool-local cap\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n"}, "38e4c5a66d45daacca41e3b4ee7b84fa91aea478": {"t": 1785750705.6279373, "n": 7650, "h": "d71f834fcde5e636", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek: high|max; Anthropic: low|medium|high|xhigh|max). Other kinds\n# (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# excluded_paths = [\"~/.agents/skills\"] # hide convention roots without deleting folders\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n"}, "121f7085e4c490cd21e31a0f058117b78965e221": {"t": 1785750706.739556, "n": 7553, "h": "1acdbcb37a31a43c", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek: high|max; Anthropic: low|medium|high|xhigh|max). Other kinds\n# (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background once enabled.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# # Enabled MCP servers connect automatically in the background after session\n# # start. Use /mcp or the desktop MCP panel to refresh, reconnect, or disable.\n"}, "3cec9768f47d9c12435a42dca7618e75155e536a": {"t": 1785750707.8388, "n": 8002, "h": "534612846feef0e4", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[notifications]\nenabled = false # system notifications for CLI chat/run; default off\nturn_done = true # notify when a turn finishes\napproval_request = true # notify when a tool approval is waiting\nask_request = true # notify when a question is waiting\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek: high|max; Anthropic: low|medium|high|xhigh|max). Other kinds\n# (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background. Choose tier the same way as ordinary MCP servers.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n# tier = \"lazy\" # lazy | background | eager\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# tier = \"lazy\" # eager | lazy (default) | background\n# # tier guidance:\n# # eager — handshake at boot; the agent waits. Use when the plugin's\n# # tools must be in the very first turn's system prompt.\n# # lazy — placeholder tools (from cached schema, when present) until\n# # the model uses them, then handshake on-demand. Default.\n# # background — placeholder + spawn at boot in a goroutine so an idle\n# # session warms up without blocking the user.\n"}, "c0bda7ce577f66ebfb31688d98feae3e0b8a5011": {"t": 1785750708.9779246, "n": 7741, "h": "80b02d96296da9bf", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Custom /effort levels for a provider. When supported_efforts is set, the\n# /effort command exposes these levels; default_effort is what \"/effort auto\"\n# (or unset) resolves to. Leave both commented to keep the built-in defaults\n# (DeepSeek: high|max; Anthropic: low|medium|high|xhigh|max). Other kinds\n# (e.g. mimo-pro) don't expose /effort unless you opt in here.\n#\n# [[providers]]\n# name = \"mimo-custom\"\n# kind = \"openai\"\n# base_url = \"https://token-plan-cn.xiaomimimo.com/v1\"\n# model = \"mimo-v2.5-pro\"\n# api_key_env = \"MIMO_API_KEY\"\n# supported_efforts = [\"low\", \"medium\", \"high\"]\n# default_effort = \"high\"\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background. Choose tier the same way as ordinary MCP servers.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n# tier = \"lazy\" # lazy | background | eager\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# tier = \"lazy\" # eager | lazy (default) | background\n# # tier guidance:\n# # eager — handshake at boot; the agent waits. Use when the plugin's\n# # tools must be in the very first turn's system prompt.\n# # lazy — placeholder tools (from cached schema, when present) until\n# # the model uses them, then handshake on-demand. Default.\n# # background — placeholder + spawn at boot in a goroutine so an idle\n# # session warms up without blocking the user.\n"}, "a4db571df079b643cd1660a79f04251d07ef0692": {"t": 1785750710.0790813, "n": 7087, "h": "e0cb8da180f98bd2", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"off\" # off|on; off keeps plan mode manual\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background. Choose tier the same way as ordinary MCP servers.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n# tier = \"lazy\" # lazy | background | eager\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# tier = \"lazy\" # eager | lazy (default) | background\n# # tier guidance:\n# # eager — handshake at boot; the agent waits. Use when the plugin's\n# # tools must be in the very first turn's system prompt.\n# # lazy — placeholder tools (from cached schema, when present) until\n# # the model uses them, then handshake on-demand. Default.\n# # background — placeholder + spawn at boot in a goroutine so an idle\n# # session warms up without blocking the user.\n"}, "0081d8130609e14ad98d594aa6abf3ec3ee168ea": {"t": 1785750711.211392, "n": 7110, "h": "62cc08ff58b6e3b1", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"ask\" # off|ask|on; ask/on auto-enter plan mode for complex tasks\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\nsoft_compact_ratio = 0.5 # notice only; keeps the cache-first prefix intact\ncompact_ratio = 0.8 # try compacting when prompt reaches this fraction\ncompact_force_ratio = 0.9 # force compacting at this high-water mark\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background. Choose tier the same way as ordinary MCP servers.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n# tier = \"lazy\" # lazy | background | eager\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# tier = \"lazy\" # eager | lazy (default) | background\n# # tier guidance:\n# # eager — handshake at boot; the agent waits. Use when the plugin's\n# # tools must be in the very first turn's system prompt.\n# # lazy — placeholder tools (from cached schema, when present) until\n# # the model uses them, then handshake on-demand. Default.\n# # background — placeholder + spawn at boot in a goroutine so an idle\n# # session warms up without blocking the user.\n"}, "7264bb78720174fdaf967cd7b08306cde37c2d9d": {"t": 1785750712.32485, "n": 6881, "h": "35b0178a341779b8", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"ask\" # off|ask|on; ask/on auto-enter plan mode for complex tasks\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. Off by default for first-run sessions. Once enabled, the\n# runtime is fetched into a per-version cache when missing (auto_install);\n# .codegraph/ is then initialised (fast) and CodeGraph's daemon indexes in the\n# background. Choose tier the same way as ordinary MCP servers.\n# [codegraph]\n# enabled = false\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n# tier = \"lazy\" # lazy | background | eager\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# tier = \"lazy\" # eager | lazy (default) | background\n# # tier guidance:\n# # eager — handshake at boot; the agent waits. Use when the plugin's\n# # tools must be in the very first turn's system prompt.\n# # lazy — placeholder tools (from cached schema, when present) until\n# # the model uses them, then handshake on-demand. Default.\n# # background — placeholder + spawn at boot in a goroutine so an idle\n# # session warms up without blocking the user.\n"}, "576ae9c0c2b042468f344bfeabd371b842568a0a": {"t": 1785750713.3891816, "n": 6937, "h": "1aa6483e749db0e9", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"ask\" # off|ask|on; ask/on auto-enter plan mode for complex tasks\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. On by default. The runtime is fetched into a per-version\n# cache on first use (auto_install); .codegraph/ is then initialised (fast) and\n# CodeGraph's daemon indexes in the background, so installs and startup stay fast\n# even on a large repo. Disable to fall back to grep/glob, turn off auto_install\n# to require an explicit `reasonix codegraph install`, or point `path` at a launcher.\n# [codegraph]\n# enabled = true\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, enable, disable, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n# disabled_skills = [\"review\"] # hide from prompt, slash invocation, and skill tools\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# tier = \"lazy\" # eager | lazy (default) | background\n# # tier guidance:\n# # eager — handshake at boot; the agent waits. Use when the plugin's\n# # tools must be in the very first turn's system prompt.\n# # lazy — placeholder tools (from cached schema, when present) until\n# # the model uses them, then handshake on-demand. Default.\n# # background — placeholder + spawn at boot in a goroutine so an idle\n# # session warms up without blocking the user.\n"}, "08e76c35633e80cac015ec1bfeb03cd06f63c729": {"t": 1785750714.4816186, "n": 6818, "h": "9fb983a1b271c222", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[ui]\ntheme = \"auto\" # auto|dark|light; controls CLI colors only; REASONIX_THEME can override per run\n# theme_style = \"graphite\" # graphite|ember|aurora|midnight|sandstone|porcelain|linen|glacier\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"ask\" # off|ask|on; ask/on auto-enter plan mode for complex tasks\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. On by default. The runtime is fetched into a per-version\n# cache on first use (auto_install); .codegraph/ is then initialised (fast) and\n# CodeGraph's daemon indexes in the background, so installs and startup stay fast\n# even on a large repo. Disable to fall back to grep/glob, turn off auto_install\n# to require an explicit `reasonix codegraph install`, or point `path` at a launcher.\n# [codegraph]\n# enabled = true\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# tier = \"lazy\" # eager | lazy (default) | background\n# # tier guidance:\n# # eager — handshake at boot; the agent waits. Use when the plugin's\n# # tools must be in the very first turn's system prompt.\n# # lazy — placeholder tools (from cached schema, when present) until\n# # the model uses them, then handshake on-demand. Default.\n# # background — placeholder + spawn at boot in a goroutine so an idle\n# # session warms up without blocking the user.\n"}, "2c7f7e17a62582cdd35cca9d3cd8a4f89cda0819": {"t": 1785750715.555277, "n": 6618, "h": "60df87001c50ae59", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"ask\" # off|ask|on; ask/on auto-enter plan mode for complex tasks\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. On by default. The runtime is fetched into a per-version\n# cache on first use (auto_install); .codegraph/ is then initialised (fast) and\n# CodeGraph's daemon indexes in the background, so installs and startup stay fast\n# even on a large repo. Disable to fall back to grep/glob, turn off auto_install\n# to require an explicit `reasonix codegraph install`, or point `path` at a launcher.\n# [codegraph]\n# enabled = true\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# tier = \"lazy\" # eager | lazy (default) | background\n# # tier guidance:\n# # eager — handshake at boot; the agent waits. Use when the plugin's\n# # tools must be in the very first turn's system prompt.\n# # lazy — placeholder tools (from cached schema, when present) until\n# # the model uses them, then handshake on-demand. Default.\n# # background — placeholder + spawn at boot in a goroutine so an idle\n# # session warms up without blocking the user.\n"}, "4903fd28cc22356a6178b8deef86535cb566eb49": {"t": 1785750716.6436143, "n": 6509, "h": "de2e4af15346a524", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"ask\" # off|ask|on; ask/on auto-enter plan mode for complex tasks\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. On by default. The runtime is fetched into a per-version\n# cache on first use (auto_install); .codegraph/ is then initialised (fast) and\n# CodeGraph's daemon indexes in the background, so installs and startup stay fast\n# even on a large repo. Disable to fall back to grep/glob, turn off auto_install\n# to require an explicit `reasonix codegraph install`, or point `path` at a launcher.\n# [codegraph]\n# enabled = true\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n# tier = \"lazy\" # eager | lazy (default) | background\n# # tier guidance:\n# # eager — handshake at boot; the agent waits. Use when the plugin's\n# # tools must be in the very first turn's system prompt.\n# # lazy — placeholder tools (from cached schema, when present) until\n# # the model uses them, then handshake on-demand. Default.\n# # background — placeholder + spawn at boot in a goroutine so an idle\n# # session warms up without blocking the user.\n"}, "3bf6a6111a5b3ec16dc3178f0c8fcc7930206c00": {"t": 1785750717.7208433, "n": 6088, "h": "53bc0389040b50fa", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"ask\" # off|ask|on; ask/on auto-enter plan mode for complex tasks\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n# DeepSeek thinking mode is enabled by default; effort: high | max | off.\neffort = \"high\"\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. On by default. The runtime is fetched into a per-version\n# cache on first use (auto_install); .codegraph/ is then initialised (fast) and\n# CodeGraph's daemon indexes in the background, so installs and startup stay fast\n# even on a large repo. Disable to fall back to grep/glob, turn off auto_install\n# to require an explicit `reasonix codegraph install`, or point `path` at a launcher.\n# [codegraph]\n# enabled = true\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n"}, "b05e7cbe75838aeafce106f29c19bcf1ce46a7c4": {"t": 1785750720.4307375, "n": 5993, "h": "28654dca38078f40", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\nauto_plan = \"ask\" # off|ask|on; ask/on auto-enter plan mode for complex tasks\n# auto_plan_classifier = \"deepseek-flash\" # optional; only used for borderline tasks\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. On by default. The runtime is fetched into a per-version\n# cache on first use (auto_install); .codegraph/ is then initialised (fast) and\n# CodeGraph's daemon indexes in the background, so installs and startup stay fast\n# even on a large repo. Disable to fall back to grep/glob, turn off auto_install\n# to require an explicit `reasonix codegraph install`, or point `path` at a launcher.\n# [codegraph]\n# enabled = true\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n"}, "9ccc3f59db7dec16e705a4e5cf98148666a0f0d6": {"t": 1785750721.5350485, "n": 5824, "h": "1f336d341d7ef166", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. On by default. The runtime is fetched into a per-version\n# cache on first use (auto_install); .codegraph/ is then initialised (fast) and\n# CodeGraph's daemon indexes in the background, so installs and startup stay fast\n# even on a large repo. Disable to fall back to grep/glob, turn off auto_install\n# to require an explicit `reasonix codegraph install`, or point `path` at a launcher.\n# [codegraph]\n# enabled = true\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# A custom status line: a command whose first stdout line replaces the built-in\n# data row. It receives {\"model\",\"contextUsed\",\"contextWindow\"} as JSON on stdin.\n# [statusline]\n# command = \"my-statusline.sh\"\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n"}, "605aa1f28a908369cdce3aef2b91a20342610fd2": {"t": 1785750723.241787, "n": 5615, "h": "bf2323c7a96f224f", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n# output_style = \"explanatory\" # persona/tone folded into the prompt: explanatory | learning | concise,\n# or a custom .reasonix/output-styles/.md ; empty = default\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. On by default. The runtime is fetched into a per-version\n# cache on first use (auto_install); .codegraph/ is then initialised (fast) and\n# CodeGraph's daemon indexes in the background, so installs and startup stay fast\n# even on a large repo. Disable to fall back to grep/glob, turn off auto_install\n# to require an explicit `reasonix codegraph install`, or point `path` at a launcher.\n# [codegraph]\n# enabled = true\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n"}, "4660ec20419caefed1848fc6478dd537b0a4edc5": {"t": 1785750724.318426, "n": 5410, "h": "2933a5c186005172", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n# subagent_model = \"deepseek-pro\" # optional default for runAs=subagent skills\n# subagent_models = { review = \"deepseek-pro\", security_review = \"deepseek-pro\" } # per-skill overrides\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. On by default. The runtime is fetched into a per-version\n# cache on first use (auto_install); .codegraph/ is then initialised (fast) and\n# CodeGraph's daemon indexes in the background, so installs and startup stay fast\n# even on a large repo. Disable to fall back to grep/glob, turn off auto_install\n# to require an explicit `reasonix codegraph install`, or point `path` at a launcher.\n# [codegraph]\n# enabled = true\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n"}, "f81035bd6c6b6462054f8a99a1278235502bb3b7": {"t": 1785750725.4058397, "n": 5223, "h": "9643c265844a31e8", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n# Extended thinking (anthropic kind only; round-trips the signed reasoning block\n# across tool calls). Omit to disable. effort: low | medium | high | xhigh | max.\nthinking = \"adaptive\"\neffort = \"high\"\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. On by default. The runtime is fetched into a per-version\n# cache on first use (auto_install); .codegraph/ is then initialised (fast) and\n# CodeGraph's daemon indexes in the background, so installs and startup stay fast\n# even on a large repo. Disable to fall back to grep/glob, turn off auto_install\n# to require an explicit `reasonix codegraph install`, or point `path` at a launcher.\n# [codegraph]\n# enabled = true\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n"}, "ef65698054b819551e240421de7fc3e5372fba19": {"t": 1785750726.4966297, "n": 5008, "h": "e4f1098075a00dfc", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n# Anthropic (Claude) — the \"anthropic\" kind speaks the Messages API directly (no\n# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:\n# this provider does not enable extended thinking and does not send temperature —\n# current Claude models reject sampling params (see internal/provider/anthropic).\n[[providers]]\nname = \"claude\"\nkind = \"anthropic\"\nmodel = \"claude-opus-4-8\"\napi_key_env = \"ANTHROPIC_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.5, input = 5, output = 25, currency = \"$\" } # per 1M tokens\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. On by default. The runtime is fetched into a per-version\n# cache on first use (auto_install); .codegraph/ is then initialised (fast) and\n# CodeGraph's daemon indexes in the background, so installs and startup stay fast\n# even on a large repo. Disable to fall back to grep/glob, turn off auto_install\n# to require an explicit `reasonix codegraph install`, or point `path` at a launcher.\n# [codegraph]\n# enabled = true\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n"}, "cd66da62df874187b9c8930af649d738ad44733c": {"t": 1785750727.5903845, "n": 4417, "h": "937fe0b5f430c078", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# a built-in MCP server giving the agent codegraph_* search / context / explore /\n# trace / node tools. On by default. The runtime is fetched into a per-version\n# cache on first use (auto_install); .codegraph/ is then initialised (fast) and\n# CodeGraph's daemon indexes in the background, so installs and startup stay fast\n# even on a large repo. Disable to fall back to grep/glob, turn off auto_install\n# to require an explicit `reasonix codegraph install`, or point `path` at a launcher.\n# [codegraph]\n# enabled = true\n# auto_install = true\n# path = \"\" # empty = cache, then PATH, then a bundle beside reasonix\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n"}, "1e68e2e01894db61e0cd46458af3bf9064a76301": {"t": 1785750728.6843438, "n": 4371, "h": "dfefe5525727df0e", "c": "# Reasonix configuration.\n# Resolution order: flag > ./reasonix.toml > ~/.config/reasonix/config.toml > built-in defaults.\n# Secrets come from the environment via api_key_env; never put keys here.\n\ndefault_model = \"deepseek\" # a provider name (→ its default model) or \"provider/model\"\n# language = \"zh\" # ui language; empty = auto-detect from $LANG / $REASONIX_LANG\n\n[agent]\nsystem_prompt = \"\"\"\nYou are Reasonix, a coding agent focused on executing code tasks.\nUse the provided tools to read and write files and run shell commands.\nPrinciples: understand the request before acting; verify with tools instead of\nguessing; keep changes minimal and correct; briefly summarize what you did.\"\"\"\n# system_prompt_file = \"prompts/system.md\" # overrides system_prompt when set\nmax_steps = 25\ntemperature = 0.0\n# planner_model = \"mimo-pro\" # optional: enable two-model collaboration\n\n# A provider is a vendor endpoint (one base_url + key) that offers one or more\n# models. Use `models = [...]` to expose several under a single entry — switching\n# models reuses the same connection; no need to re-declare base_url/api_key.\n[[providers]]\nname = \"deepseek\"\nkind = \"openai\"\nbase_url = \"https://api.deepseek.com\"\nmodels = [\"deepseek-v4-flash\", \"deepseek-v4-pro\"]\ndefault = \"deepseek-v4-flash\" # optional; defaults to the first of `models`\napi_key_env = \"DEEPSEEK_API_KEY\"\ncontext_window = 1000000\nprice = { cache_hit = 0.02, input = 1, output = 2, currency = \"¥\" } # per 1M tokens\n\n# A single-model provider still works (use `model = \"...\"`) — pick this form when a\n# model needs its own base_url / context_window / price (e.g. the two MiMo SKUs\n# below have different context windows).\nprice = { cache_hit = 0.025, input = 3, output = 6, currency = \"¥\" } # per 1M tokens\n\n[[providers]]\nname = \"mimo-pro\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2.5-pro\" # thinking-mode on by default; reasoning_content rendered dim above the answer\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 1000000\n\n[[providers]]\nname = \"mimo-flash\"\nkind = \"openai\"\nbase_url = \"https://api.xiaomimimo.com/v1\"\nmodel = \"mimo-v2-flash\" # thinking-mode off by default; cheaper / faster\napi_key_env = \"MIMO_API_KEY\"\ncontext_window = 65536\n\n[tools]\nenabled = [] # empty = all built-in tools\n\n# CodeGraph code intelligence (symbol/call-graph search via tree-sitter + SQLite),\n# shipped as a built-in MCP server. On by default: when the bundle resolves (beside\n# the reasonix binary, then PATH), the agent gains codegraph_* search / context /\n# explore / trace / node tools. .codegraph/ is initialised on first use (fast); the\n# actual indexing runs in CodeGraph's daemon in the background, so startup never\n# blocks even on a large repo. A missing bundle is a silent no-op. Disable to fall\n# back to grep/glob, or point `path` at a specific launcher.\n# [codegraph]\n# enabled = true\n# path = \"\" # e.g. \"~/.local/bin/codegraph\"; empty = bundle, then PATH\n\n# Skills are invokable playbooks (SKILL.md / .md with frontmatter), found\n# under .reasonix/skills, .agents/skills, .agent/skills, .claude/skills (project)\n# and the same dirs under ~ (global). Built-ins (explore/research/review/security-review/test)\n# ship out of the box. The model invokes them via run_skill / explore / …; you\n# invoke them via /. Manage with /skill (list, show, new, paths).\n# [skills]\n# paths = [\"~/my-skills\", \"../shared/skills\"] # extra \"custom\"-scope skill roots\n\n# Hooks run shell commands around the loop (PreToolUse / PostToolUse /\n# UserPromptSubmit / Stop). They are NOT configured here — put them in\n# settings.json: ~/.reasonix/settings.json (global, always on) and\n# /.reasonix/settings.json (project, runs only after `/hooks trust`).\n# Exit 0 = pass, exit 2 = block (PreToolUse / UserPromptSubmit only). Example\n# ~/.reasonix/settings.json:\n# { \"hooks\": {\n# \"PreToolUse\": [ { \"match\": \"bash\", \"command\": \"my-guard.sh\" } ],\n# \"PostToolUse\": [ { \"match\": \".*file\", \"command\": \"gofmt -w .\" } ],\n# \"Stop\": [ { \"command\": \"notify-send 'turn done'\" } ] } }\n\n# External stdio plugins (MCP-compatible); each is a standalone executable.\n# [[plugins]]\n# name = \"example\"\n# command = \"reasonix-plugin-example\"\n"}}, "benchmarks/context-maintenance-e2e/main.go": {"c480afd7570fbb1fb96ff82a0ab117c5df7768e8": {"t": 1785745627.693418, "n": 8715, "h": "5774f68b2ef3c465", "c": "// Drives the context-maintenance E2E scenarios against the real DeepSeek API:\n// seed → (idle past cache TTL) → resume A/B-compares cold-restart miss tokens with and without pruning.\npackage main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/provider\"\n\t_ \"reasonix/internal/provider/openai\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n)\n\nconst (\n\tmodel = \"deepseek-v4-flash\"\n\tbaseURL = \"https://api.deepseek.com\"\n\tfatResults = 20\n\tfatBytes = 12_000\n)\n\nfunc prov() provider.Provider {\n\tkey := os.Getenv(\"DEEPSEEK_API_KEY\")\n\tif key == \"\" {\n\t\tfmt.Fprintln(os.Stderr, \"DEEPSEEK_API_KEY not set\")\n\t\tos.Exit(1)\n\t}\n\tp, err := provider.New(\"openai\", provider.Config{Name: \"e2e\", BaseURL: baseURL, Model: model, APIKey: key})\n\tif err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\treturn p\n}\n\nfunc fakeGoFile(nonce string, i, size int) string {\n\tvar b strings.Builder\n\tfmt.Fprintf(&b, \"// module %s file%02d\\npackage stress\\n\\n\", nonce, i)\n\tline := 0\n\tfor b.Len() < size {\n\t\tfmt.Fprintf(&b, \"func helper_%s_%02d_%04d(x int) int { return x*%d + %d }\\n\", nonce, i, line, line+3, line*7)\n\t\tline++\n\t}\n\treturn b.String()\n}\n\nfunc seedSession(nonce string) *agent.Session {\n\ts := agent.NewSession(\"You are a terse coding agent reviewing a Go codebase.\")\n\ts.Add(provider.Message{Role: provider.RoleUser, Content: \"Review every file in module \" + nonce + \" one by one. Keep notes short.\"})\n\tfor i := 0; i < fatResults; i++ {\n\t\tid := fmt.Sprintf(\"c%02d\", i)\n\t\tname := fmt.Sprintf(\"src/file%02d.go\", i)\n\t\ts.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: \"read_file\", Arguments: fmt.Sprintf(`{\"path\":%q}`, name)}}})\n\t\ts.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: \"read_file\", Content: fakeGoFile(nonce, i, fatBytes)})\n\t\ts.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf(\"Reviewed %s.\", name)})\n\t}\n\treturn s\n}\n\n// oneShot appends a user message and runs a single completion, returning usage.\nfunc oneShot(p provider.Provider, msgs []provider.Message, question string) (provider.Usage, error) {\n\treq := provider.Request{\n\t\tMessages: append(append([]provider.Message(nil), msgs...), provider.Message{Role: provider.RoleUser, Content: question}),\n\t\tMaxTokens: 32,\n\t}\n\tch, err := p.Stream(context.Background(), req)\n\tif err != nil {\n\t\treturn provider.Usage{}, err\n\t}\n\tvar u provider.Usage\n\tfor c := range ch {\n\t\tswitch c.Type {\n\t\tcase provider.ChunkUsage:\n\t\t\tu = *c.Usage\n\t\tcase provider.ChunkError:\n\t\t\treturn u, c.Err\n\t\t}\n\t}\n\treturn u, nil\n}\n\ntype meta struct {\n\tSeededAt time.Time `json:\"seeded_at\"`\n\tNonces map[string]string `json:\"nonces\"`\n\tSeedUse map[string]provider.Usage `json:\"seed_usage\"`\n}\n\nfunc seed(dir string) {\n\tp := prov()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\tm := meta{SeededAt: time.Now(), Nonces: map[string]string{}, SeedUse: map[string]provider.Usage{}}\n\tfor _, arm := range []string{\"pruned\", \"control\"} {\n\t\tnonce := fmt.Sprintf(\"%s%d\", arm, time.Now().UnixNano()%1_000_000)\n\t\ts := seedSession(nonce)\n\t\tu, err := oneShot(p, s.Snapshot(), \"How many files have you reviewed so far? Reply with just the number.\")\n\t\tif err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"seed %s: %v\\n\", arm, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif err := s.Save(filepath.Join(dir, arm+\".jsonl\")); err != nil {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tm.Nonces[arm] = nonce\n\t\tm.SeedUse[arm] = u\n\t\tfmt.Printf(\"seeded %-7s prompt=%d hit=%d miss=%d\\n\", arm, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tb, _ := json.MarshalIndent(m, \"\", \" \")\n\tif err := os.WriteFile(filepath.Join(dir, \"meta.json\"), b, 0o644); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n}\n\nfunc resume(dir string) {\n\tp := prov()\n\traw, err := os.ReadFile(filepath.Join(dir, \"meta.json\"))\n\tif err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\tvar m meta\n\tif err := json.Unmarshal(raw, &m); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\tidle := time.Since(m.SeededAt).Round(time.Minute)\n\n\tout := map[string]provider.Usage{}\n\tfor _, arm := range []string{\"pruned\", \"control\"} {\n\t\ts, err := agent.LoadSession(filepath.Join(dir, arm+\".jsonl\"))\n\t\tif err != nil {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tprunes := 0\n\t\tif arm == \"pruned\" {\n\t\t\ta := agent.New(nil, tool.NewRegistry(), s, agent.Options{ContextWindow: 1_000_000, ArchiveDir: filepath.Join(dir, \"archive\")}, event.Discard)\n\t\t\tst, err := a.PruneStaleToolResults()\n\t\t\tif err != nil {\n\t\t\t\tfmt.Fprintln(os.Stderr, \"prune:\", err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t\tprunes = st.Results\n\t\t}\n\t\tu, err := oneShot(p, s.Snapshot(), \"Which file did you review first? Reply with just the path.\")\n\t\tif err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"resume %s: %v\\n\", arm, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tout[arm] = u\n\t\tfmt.Printf(\"resume %-7s idle=%s pruned=%d prompt=%d hit=%d miss=%d\\n\", arm, idle, prunes, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tb, _ := json.MarshalIndent(map[string]any{\"idle\": idle.String(), \"resume_usage\": out}, \"\", \" \")\n\tif err := os.WriteFile(filepath.Join(dir, fmt.Sprintf(\"resume-%d.json\", time.Now().Unix())), b, 0o644); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\n\tc, pr := out[\"control\"], out[\"pruned\"]\n\tif c.CacheMissTokens > 0 {\n\t\tfmt.Printf(\"\\ncold-restart miss tokens: control=%d pruned=%d (%.0f%% reduction)\\n\",\n\t\t\tc.CacheMissTokens, pr.CacheMissTokens, 100*(1-float64(pr.CacheMissTokens)/float64(c.CacheMissTokens)))\n\t}\n}\n\n// comprehension checks that the agent re-reads a file behind a prune placeholder\n// instead of hallucinating: the answer is a number that exists only in the file.\nfunc comprehension(trials int) {\n\tp := prov()\n\tpass := 0\n\tfor t := 0; t < trials; t++ {\n\t\tdir, err := os.MkdirTemp(\"\", \"cm-e2e-\")\n\t\tif err != nil {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tsecret := fmt.Sprintf(\"%d\", 1000+time.Now().UnixNano()%9000)\n\t\tcontent := \"package cfg\\n\\n// retention floor, milliseconds\\nconst cacheRetentionFloor = \" + secret + \"\\n\" + strings.Repeat(\"// padding line filler for prune eligibility\\n\", 400)\n\t\tif err := os.WriteFile(filepath.Join(dir, \"config.go\"), []byte(content), 0o644); err != nil {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\ts := agent.NewSession(\"You are a terse coding agent. Use tools when you need file contents.\")\n\t\ts.Add(provider.Message{Role: provider.RoleUser, Content: \"Read config.go and note its constants.\"})\n\t\ts.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: \"r1\", Name: \"read_file\", Arguments: `{\"path\":\"config.go\"}`}}})\n\t\ts.Add(provider.Message{Role: provider.RoleTool, ToolCallID: \"r1\", Name: \"read_file\", Content: content})\n\t\ts.Add(provider.Message{Role: provider.RoleAssistant, Content: \"Noted the constants in config.go.\"})\n\t\tfor i := 0; i < 4; i++ {\n\t\t\ts.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf(\"ack %d\", i)})\n\t\t\ts.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\t\t}\n\n\t\treg := tool.NewRegistry()\n\t\tfor _, tl := range (builtin.Workspace{Dir: dir}).Tools(\"read_file\") {\n\t\t\treg.Add(tl)\n\t\t}\n\t\ta := agent.New(p, reg, s, agent.Options{ContextWindow: 2000, RecentKeep: 2, MaxSteps: 5}, event.Discard)\n\t\tst, err := a.PruneStaleToolResults()\n\t\tif err != nil || st.Results == 0 {\n\t\t\tfmt.Fprintf(os.Stderr, \"trial %d: prune did not fire (st=%+v err=%v)\\n\", t, st, err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\terr = a.Run(context.Background(), \"What is the exact numeric value of cacheRetentionFloor in config.go? Reply with just the number.\")\n\t\treRead, answered := false, false\n\t\tfor _, msg := range s.Snapshot() {\n\t\t\tif msg.Role == provider.RoleTool && strings.Contains(msg.Content, \"cacheRetentionFloor = \"+secret) {\n\t\t\t\treRead = true\n\t\t\t}\n\t\t\tif msg.Role == provider.RoleAssistant && strings.Contains(msg.Content, secret) {\n\t\t\t\tanswered = true\n\t\t\t}\n\t\t}\n\t\tok := err == nil && reRead && answered\n\t\tif ok {\n\t\t\tpass++\n\t\t}\n\t\tfmt.Printf(\"trial %d: re-read=%v answered=%v err=%v\\n\", t, reRead, answered, err)\n\t\tos.RemoveAll(dir)\n\t}\n\tfmt.Printf(\"\\ncomprehension: %d/%d passed\\n\", pass, trials)\n\tif pass < trials {\n\t\tos.Exit(1)\n\t}\n}\n\nfunc main() {\n\tdir := flag.String(\"dir\", \"benchmarks/context-maintenance-e2e/run\", \"state directory for seed/resume\")\n\ttrials := flag.Int(\"trials\", 5, \"comprehension trials\")\n\tflag.Parse()\n\tswitch flag.Arg(0) {\n\tcase \"seed\":\n\t\tseed(*dir)\n\tcase \"resume\":\n\t\tresume(*dir)\n\tcase \"comprehension\":\n\t\tcomprehension(*trials)\n\tdefault:\n\t\tfmt.Fprintln(os.Stderr, \"usage: context-maintenance-e2e [seed|resume|comprehension]\")\n\t\tos.Exit(1)\n\t}\n}\n"}, "3025f5af2a8f4364eea52db323e402edc27e84a9": {"t": 1785750694.3053803, "n": 8715, "h": "5774f68b2ef3c465", "c": "// Drives the context-maintenance E2E scenarios against the real DeepSeek API:\n// seed → (idle past cache TTL) → resume A/B-compares cold-restart miss tokens with and without pruning.\npackage main\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"flag\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/provider\"\n\t_ \"reasonix/internal/provider/openai\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n)\n\nconst (\n\tmodel = \"deepseek-v4-flash\"\n\tbaseURL = \"https://api.deepseek.com\"\n\tfatResults = 20\n\tfatBytes = 12_000\n)\n\nfunc prov() provider.Provider {\n\tkey := os.Getenv(\"DEEPSEEK_API_KEY\")\n\tif key == \"\" {\n\t\tfmt.Fprintln(os.Stderr, \"DEEPSEEK_API_KEY not set\")\n\t\tos.Exit(1)\n\t}\n\tp, err := provider.New(\"openai\", provider.Config{Name: \"e2e\", BaseURL: baseURL, Model: model, APIKey: key})\n\tif err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\treturn p\n}\n\nfunc fakeGoFile(nonce string, i, size int) string {\n\tvar b strings.Builder\n\tfmt.Fprintf(&b, \"// module %s file%02d\\npackage stress\\n\\n\", nonce, i)\n\tline := 0\n\tfor b.Len() < size {\n\t\tfmt.Fprintf(&b, \"func helper_%s_%02d_%04d(x int) int { return x*%d + %d }\\n\", nonce, i, line, line+3, line*7)\n\t\tline++\n\t}\n\treturn b.String()\n}\n\nfunc seedSession(nonce string) *agent.Session {\n\ts := agent.NewSession(\"You are a terse coding agent reviewing a Go codebase.\")\n\ts.Add(provider.Message{Role: provider.RoleUser, Content: \"Review every file in module \" + nonce + \" one by one. Keep notes short.\"})\n\tfor i := 0; i < fatResults; i++ {\n\t\tid := fmt.Sprintf(\"c%02d\", i)\n\t\tname := fmt.Sprintf(\"src/file%02d.go\", i)\n\t\ts.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: \"read_file\", Arguments: fmt.Sprintf(`{\"path\":%q}`, name)}}})\n\t\ts.Add(provider.Message{Role: provider.RoleTool, ToolCallID: id, Name: \"read_file\", Content: fakeGoFile(nonce, i, fatBytes)})\n\t\ts.Add(provider.Message{Role: provider.RoleAssistant, Content: fmt.Sprintf(\"Reviewed %s.\", name)})\n\t}\n\treturn s\n}\n\n// oneShot appends a user message and runs a single completion, returning usage.\nfunc oneShot(p provider.Provider, msgs []provider.Message, question string) (provider.Usage, error) {\n\treq := provider.Request{\n\t\tMessages: append(append([]provider.Message(nil), msgs...), provider.Message{Role: provider.RoleUser, Content: question}),\n\t\tMaxTokens: 32,\n\t}\n\tch, err := p.Stream(context.Background(), req)\n\tif err != nil {\n\t\treturn provider.Usage{}, err\n\t}\n\tvar u provider.Usage\n\tfor c := range ch {\n\t\tswitch c.Type {\n\t\tcase provider.ChunkUsage:\n\t\t\tu = *c.Usage\n\t\tcase provider.ChunkError:\n\t\t\treturn u, c.Err\n\t\t}\n\t}\n\treturn u, nil\n}\n\ntype meta struct {\n\tSeededAt time.Time `json:\"seeded_at\"`\n\tNonces map[string]string `json:\"nonces\"`\n\tSeedUse map[string]provider.Usage `json:\"seed_usage\"`\n}\n\nfunc seed(dir string) {\n\tp := prov()\n\tif err := os.MkdirAll(dir, 0o755); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\tm := meta{SeededAt: time.Now(), Nonces: map[string]string{}, SeedUse: map[string]provider.Usage{}}\n\tfor _, arm := range []string{\"pruned\", \"control\"} {\n\t\tnonce := fmt.Sprintf(\"%s%d\", arm, time.Now().UnixNano()%1_000_000)\n\t\ts := seedSession(nonce)\n\t\tu, err := oneShot(p, s.Snapshot(), \"How many files have you reviewed so far? Reply with just the number.\")\n\t\tif err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"seed %s: %v\\n\", arm, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tif err := s.Save(filepath.Join(dir, arm+\".jsonl\")); err != nil {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tm.Nonces[arm] = nonce\n\t\tm.SeedUse[arm] = u\n\t\tfmt.Printf(\"seeded %-7s prompt=%d hit=%d miss=%d\\n\", arm, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tb, _ := json.MarshalIndent(m, \"\", \" \")\n\tif err := os.WriteFile(filepath.Join(dir, \"meta.json\"), b, 0o644); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n}\n\nfunc resume(dir string) {\n\tp := prov()\n\traw, err := os.ReadFile(filepath.Join(dir, \"meta.json\"))\n\tif err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\tvar m meta\n\tif err := json.Unmarshal(raw, &m); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\tidle := time.Since(m.SeededAt).Round(time.Minute)\n\n\tout := map[string]provider.Usage{}\n\tfor _, arm := range []string{\"pruned\", \"control\"} {\n\t\ts, err := agent.LoadSession(filepath.Join(dir, arm+\".jsonl\"))\n\t\tif err != nil {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tprunes := 0\n\t\tif arm == \"pruned\" {\n\t\t\ta := agent.New(nil, tool.NewRegistry(), s, agent.Options{ContextWindow: 1_000_000, ArchiveDir: filepath.Join(dir, \"archive\")}, event.Discard)\n\t\t\tst, err := a.PruneStaleToolResults()\n\t\t\tif err != nil {\n\t\t\t\tfmt.Fprintln(os.Stderr, \"prune:\", err)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t\tprunes = st.Results\n\t\t}\n\t\tu, err := oneShot(p, s.Snapshot(), \"Which file did you review first? Reply with just the path.\")\n\t\tif err != nil {\n\t\t\tfmt.Fprintf(os.Stderr, \"resume %s: %v\\n\", arm, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tout[arm] = u\n\t\tfmt.Printf(\"resume %-7s idle=%s pruned=%d prompt=%d hit=%d miss=%d\\n\", arm, idle, prunes, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens)\n\t}\n\tb, _ := json.MarshalIndent(map[string]any{\"idle\": idle.String(), \"resume_usage\": out}, \"\", \" \")\n\tif err := os.WriteFile(filepath.Join(dir, fmt.Sprintf(\"resume-%d.json\", time.Now().Unix())), b, 0o644); err != nil {\n\t\tfmt.Fprintln(os.Stderr, err)\n\t\tos.Exit(1)\n\t}\n\n\tc, pr := out[\"control\"], out[\"pruned\"]\n\tif c.CacheMissTokens > 0 {\n\t\tfmt.Printf(\"\\ncold-restart miss tokens: control=%d pruned=%d (%.0f%% reduction)\\n\",\n\t\t\tc.CacheMissTokens, pr.CacheMissTokens, 100*(1-float64(pr.CacheMissTokens)/float64(c.CacheMissTokens)))\n\t}\n}\n\n// comprehension checks that the agent re-reads a file behind a prune placeholder\n// instead of hallucinating: the answer is a number that exists only in the file.\nfunc comprehension(trials int) {\n\tp := prov()\n\tpass := 0\n\tfor t := 0; t < trials; t++ {\n\t\tdir, err := os.MkdirTemp(\"\", \"cm-e2e-\")\n\t\tif err != nil {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t\tos.Exit(1)\n\t\t}\n\t\tsecret := fmt.Sprintf(\"%d\", 1000+time.Now().UnixNano()%9000)\n\t\tcontent := \"package cfg\\n\\n// retention floor, milliseconds\\nconst cacheRetentionFloor = \" + secret + \"\\n\" + strings.Repeat(\"// padding line filler for prune eligibility\\n\", 400)\n\t\tif err := os.WriteFile(filepath.Join(dir, \"config.go\"), []byte(content), 0o644); err != nil {\n\t\t\tfmt.Fprintln(os.Stderr, err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\ts := agent.NewSession(\"You are a terse coding agent. Use tools when you need file contents.\")\n\t\ts.Add(provider.Message{Role: provider.RoleUser, Content: \"Read config.go and note its constants.\"})\n\t\ts.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: \"r1\", Name: \"read_file\", Arguments: `{\"path\":\"config.go\"}`}}})\n\t\ts.Add(provider.Message{Role: provider.RoleTool, ToolCallID: \"r1\", Name: \"read_file\", Content: content})\n\t\ts.Add(provider.Message{Role: provider.RoleAssistant, Content: \"Noted the constants in config.go.\"})\n\t\tfor i := 0; i < 4; i++ {\n\t\t\ts.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf(\"ack %d\", i)})\n\t\t\ts.Add(provider.Message{Role: provider.RoleAssistant, Content: \"ok\"})\n\t\t}\n\n\t\treg := tool.NewRegistry()\n\t\tfor _, tl := range (builtin.Workspace{Dir: dir}).Tools(\"read_file\") {\n\t\t\treg.Add(tl)\n\t\t}\n\t\ta := agent.New(p, reg, s, agent.Options{ContextWindow: 2000, RecentKeep: 2, MaxSteps: 5}, event.Discard)\n\t\tst, err := a.PruneStaleToolResults()\n\t\tif err != nil || st.Results == 0 {\n\t\t\tfmt.Fprintf(os.Stderr, \"trial %d: prune did not fire (st=%+v err=%v)\\n\", t, st, err)\n\t\t\tos.Exit(1)\n\t\t}\n\n\t\terr = a.Run(context.Background(), \"What is the exact numeric value of cacheRetentionFloor in config.go? Reply with just the number.\")\n\t\treRead, answered := false, false\n\t\tfor _, msg := range s.Snapshot() {\n\t\t\tif msg.Role == provider.RoleTool && strings.Contains(msg.Content, \"cacheRetentionFloor = \"+secret) {\n\t\t\t\treRead = true\n\t\t\t}\n\t\t\tif msg.Role == provider.RoleAssistant && strings.Contains(msg.Content, secret) {\n\t\t\t\tanswered = true\n\t\t\t}\n\t\t}\n\t\tok := err == nil && reRead && answered\n\t\tif ok {\n\t\t\tpass++\n\t\t}\n\t\tfmt.Printf(\"trial %d: re-read=%v answered=%v err=%v\\n\", t, reRead, answered, err)\n\t\tos.RemoveAll(dir)\n\t}\n\tfmt.Printf(\"\\ncomprehension: %d/%d passed\\n\", pass, trials)\n\tif pass < trials {\n\t\tos.Exit(1)\n\t}\n}\n\nfunc main() {\n\tdir := flag.String(\"dir\", \"benchmarks/context-maintenance-e2e/run\", \"state directory for seed/resume\")\n\ttrials := flag.Int(\"trials\", 5, \"comprehension trials\")\n\tflag.Parse()\n\tswitch flag.Arg(0) {\n\tcase \"seed\":\n\t\tseed(*dir)\n\tcase \"resume\":\n\t\tresume(*dir)\n\tcase \"comprehension\":\n\t\tcomprehension(*trials)\n\tdefault:\n\t\tfmt.Fprintln(os.Stderr, \"usage: context-maintenance-e2e [seed|resume|comprehension]\")\n\t\tos.Exit(1)\n\t}\n}\n"}}, "internal/boot/boot_test.go": {"c480afd7570fbb1fb96ff82a0ab117c5df7768e8": {"t": 1785745639.2661586, "n": 70695, "h": "ea0fec2b9b0cb7ca", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/agent/testutil\"\n\t\"reasonix/internal/builtinmcp\"\n\t\"reasonix/internal/codegraph\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestBuildRegistersUsableHistoryAndMemoryRetrievalTools(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-retrieval-tool-test\"\nmodel = \"x\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"sessions\")\n\tif err := os.MkdirAll(sessionDir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tpast := agent.NewSession(\"\")\n\tpast.Add(provider.Message{Role: provider.RoleUser, Content: \"Should the history layer use vector embeddings?\"})\n\tpast.Add(provider.Message{Role: provider.RoleAssistant, Content: \"Decision: port lightweight BM25 history retrieval without a vector database.\"})\n\tif err := past.Save(filepath.Join(sessionDir, \"past.jsonl\")); err != nil {\n\t\tt.Fatalf(\"save past session: %v\", err)\n\t}\n\n\tstore := memory.StoreFor(config.MemoryUserDir(), dir)\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"synthesis-cache-policy\",\n\t\tDescription: \"Stable conclusions should be reused from memory\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t}); err != nil {\n\t\tt.Fatalf(\"save memory: %v\", err)\n\t}\n\n\tregisterBootRetrievalToolTestProvider()\n\tprov := testutil.NewMock(\"boot-retrieval-tool-test\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"history-1\", Name: \"history\", Arguments: `{\"operation\":\"search\",\"query\":\"BM25 vector database\",\"scope\":\"project\",\"limit\":5}`},\n\t\t\t{ID: \"memory-1\", Name: \"memory\", Arguments: `{\"operation\":\"search\",\"query\":\"synthesis cache stable conclusion\",\"limit\":5}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootRetrievalToolTestProvider(t, prov)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tfor _, forbidden := range []string{\n\t\t\"Decision: port lightweight BM25 history retrieval without a vector database.\",\n\t\t\"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t} {\n\t\tif strings.Contains(sys, forbidden) {\n\t\t\tt.Fatalf(\"retrieval content should stay behind on-demand tools, not enter the cache-stable system prompt:\\n%s\", sys)\n\t\t}\n\t}\n\n\tif err := ctrl.Run(context.Background(), \"recover past context\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) == 0 {\n\t\tt.Fatal(\"provider received no requests\")\n\t}\n\tfor _, want := range []string{\"history\", \"memory\", \"remember\", \"forget\"} {\n\t\tif !requestHasTool(reqs[0], want) {\n\t\t\tt.Fatalf(\"first request missing tool %q; tools=%v\", want, toolSchemaNames(reqs[0].Tools))\n\t\t}\n\t}\n\tassertToolOrder(t, reqs[0].Tools, []string{\"forget\", \"history\", \"memory\", \"remember\"})\n\n\ttoolResults := map[string]string{}\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolResults[msg.Name] += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolResults[\"history\"], \"port lightweight BM25 history retrieval\") {\n\t\tt.Fatalf(\"history tool result did not include saved session decision:\\n%s\", toolResults[\"history\"])\n\t}\n\tif !strings.Contains(toolResults[\"memory\"], \"synthesis-cache-policy\") ||\n\t\t!strings.Contains(toolResults[\"memory\"], \"stable conclusion\") {\n\t\tt.Fatalf(\"memory tool result did not include saved memory:\\n%s\", toolResults[\"memory\"])\n\t}\n}\n\nconst bootRetrievalToolTestProviderKind = \"boot-retrieval-tool-test\"\n\nvar (\n\tbootRetrievalToolTestProviderOnce sync.Once\n\tbootRetrievalToolTestProviderCurrent *testutil.MockProvider\n\tbootRetrievalToolTestProviderMu sync.Mutex\n)\n\nfunc registerBootRetrievalToolTestProvider() {\n\tbootRetrievalToolTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootRetrievalToolTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\t\tdefer bootRetrievalToolTestProviderMu.Unlock()\n\t\t\tif bootRetrievalToolTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot retrieval tool test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootRetrievalToolTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootRetrievalToolTestProvider(t *testing.T, p *testutil.MockProvider) {\n\tt.Helper()\n\tbootRetrievalToolTestProviderMu.Lock()\n\tbootRetrievalToolTestProviderCurrent = p\n\tbootRetrievalToolTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\tif bootRetrievalToolTestProviderCurrent == p {\n\t\t\tbootRetrievalToolTestProviderCurrent = nil\n\t\t}\n\t\tbootRetrievalToolTestProviderMu.Unlock()\n\t})\n}\n\nconst bootTokenProfileTestProviderKind = \"boot-token-profile-test\"\n\nvar (\n\tbootTokenProfileTestProviderOnce sync.Once\n\tbootTokenProfileTestProviderCurrent *testutil.MockProvider\n\tbootTokenProfileTestProviderMu sync.Mutex\n)\n\nfunc registerBootTokenProfileTestProvider() {\n\tbootTokenProfileTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootTokenProfileTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootTokenProfileTestProviderMu.Lock()\n\t\t\tdefer bootTokenProfileTestProviderMu.Unlock()\n\t\t\tif bootTokenProfileTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot token profile test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootTokenProfileTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootTokenProfileTestProvider(t *testing.T, p *testutil.MockProvider) {\n\tt.Helper()\n\tbootTokenProfileTestProviderMu.Lock()\n\tbootTokenProfileTestProviderCurrent = p\n\tbootTokenProfileTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootTokenProfileTestProviderMu.Lock()\n\t\tif bootTokenProfileTestProviderCurrent == p {\n\t\t\tbootTokenProfileTestProviderCurrent = nil\n\t\t}\n\t\tbootTokenProfileTestProviderMu.Unlock()\n\t})\n}\n\nfunc requestHasTool(req provider.Request, name string) bool {\n\tfor _, schema := range req.Tools {\n\t\tif schema.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc requestHasToolPrefix(req provider.Request, prefix string) bool {\n\tfor _, schema := range req.Tools {\n\t\tif strings.HasPrefix(schema.Name, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc toolSchemaNames(tools []provider.ToolSchema) []string {\n\tnames := make([]string, 0, len(tools))\n\tfor _, schema := range tools {\n\t\tnames = append(names, schema.Name)\n\t}\n\treturn names\n}\n\nfunc assertToolOrder(t *testing.T, tools []provider.ToolSchema, want []string) {\n\tt.Helper()\n\tnames := toolSchemaNames(tools)\n\tnext := 0\n\tfor _, name := range names {\n\t\tif next < len(want) && name == want[next] {\n\t\t\tnext++\n\t\t}\n\t}\n\tif next != len(want) {\n\t\tt.Fatalf(\"tool order changed; provider-visible tool schema order affects prompt-cache shape.\\nwant subsequence: %v\\n got: %v\", want, names)\n\t}\n}\n\nfunc firstTokenProfileRequest(t *testing.T, tokenMode string) provider.Request {\n\tt.Helper()\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-profile\", testutil.Turn{Text: \"done\"})\n\tsetBootTokenProfileTestProvider(t, prov)\n\n\topts := Options{Sink: event.Discard}\n\tif tokenMode != \"\" {\n\t\topts.TokenMode = tokenMode\n\t}\n\tctrl, err := Build(context.Background(), opts)\n\tif err != nil {\n\t\tt.Fatalf(\"Build(%q): %v\", tokenMode, err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"capture request prefix\"); err != nil {\n\t\tt.Fatalf(\"Run(%q): %v\", tokenMode, err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 1 {\n\t\tt.Fatalf(\"requests(%q) = %d, want 1\", tokenMode, len(reqs))\n\t}\n\treturn reqs[0]\n}\n\nfunc TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"first Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\tprov.setContinueRef(ref)\n\n\tif err := ctrl.Run(context.Background(), \"continue review\"); err != nil {\n\t\tt.Fatalf(\"second Run: %v\", err)\n\t}\n\tstore := agent.NewSubagentStore(filepath.Join(config.SessionDir(), \"subagents\"))\n\tmeta, err := store.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta: %v\", err)\n\t}\n\tif meta.Status != agent.SubagentFailed {\n\t\tt.Fatalf(\"status = %q, want failed\", meta.Status)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tsess, err := agent.LoadSession(filepath.Join(config.SessionDir(), \"subagents\", ref+\".jsonl\"))\n\tif err != nil {\n\t\tt.Fatalf(\"LoadSession: %v\", err)\n\t}\n\tmsgs := sess.Snapshot()\n\tif len(msgs) != 4 || msgs[1].Content != \"first skill task\" || msgs[2].Content != \"first skill answer\" || msgs[3].Content != \"second skill task\" {\n\t\tt.Fatalf(\"failed skill transcript = %+v, want first task/answer plus second task\", msgs)\n\t}\n}\n\nfunc TestBuildSubagentStoreHonorsSessionDirOverride(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\n\toverrideStore := agent.NewSubagentStore(filepath.Join(sessionDir, \"subagents\"))\n\tmeta, err := overrideStore.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta from override dir: %v\", err)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \"subagents\", ref+\".meta.json\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"subagent metadata should not be written to global session dir, stat err = %v\", err)\n\t}\n}\n\nfunc TestBuildSubagentSkillUsesLiveReasoningLanguage(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\nreasoning_language = \"zh\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tctrl.SetReasoningLanguage(\"auto\")\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.requestsSnapshot()\n\tif len(reqs) < 2 {\n\t\tt.Fatalf(\"provider requests = %d, want parent request plus skill subagent request\", len(reqs))\n\t}\n\tif got := bootLastUser(reqs[1]); strings.Contains(got, \"\") {\n\t\tt.Fatalf(\"skill subagent kept stale boot-time reasoning language after live auto update: %q\", got)\n\t}\n\tif got := bootLastUser(reqs[1]); got != \"first skill task\" {\n\t\tt.Fatalf(\"skill subagent user prompt = %q, want first skill task\", got)\n\t}\n}\n\nconst bootSubagentTestProviderKind = \"boot-subagent-test\"\n\nvar (\n\tbootSubagentTestProviderOnce sync.Once\n\tbootSubagentTestProviderCurrent *bootSubagentTestProvider\n\tbootSubagentTestProviderMu sync.Mutex\n)\n\nfunc registerBootSubagentTestProvider() {\n\tbootSubagentTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootSubagentTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootSubagentTestProviderMu.Lock()\n\t\t\tdefer bootSubagentTestProviderMu.Unlock()\n\t\t\tif bootSubagentTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot subagent test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootSubagentTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootSubagentTestProvider(t *testing.T, p *bootSubagentTestProvider) {\n\tt.Helper()\n\tbootSubagentTestProviderMu.Lock()\n\tbootSubagentTestProviderCurrent = p\n\tbootSubagentTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootSubagentTestProviderMu.Lock()\n\t\tif bootSubagentTestProviderCurrent == p {\n\t\t\tbootSubagentTestProviderCurrent = nil\n\t\t}\n\t\tbootSubagentTestProviderMu.Unlock()\n\t})\n}\n\ntype bootSubagentTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n\tcontinueRef string\n\trequests []provider.Request\n}\n\nfunc (p *bootSubagentTestProvider) Name() string { return \"boot-subagent-test\" }\n\nfunc (p *bootSubagentTestProvider) setContinueRef(ref string) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tp.continueRef = ref\n}\n\nfunc (p *bootSubagentTestProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tref := p.continueRef\n\tp.requests = append(p.requests, req)\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-1\", Name: \"review\", Arguments: `{\"task\":\"first skill task\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"first skill answer\"}, {Type: provider.ChunkDone}}\n\tcase 2:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent first done\"}, {Type: provider.ChunkDone}}\n\tcase 3:\n\t\targs, _ := json.Marshal(map[string]string{\"task\": \"second skill task\", \"continue_from\": ref})\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-2\", Name: \"review\", Arguments: string(args)}}}\n\tcase 4:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: errors.New(\"subagent skill failed\")}}\n\tcase 5:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent second done\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf(\"unexpected provider call %d\", call)}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc (p *bootSubagentTestProvider) requestsSnapshot() []provider.Request {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tout := make([]provider.Request, len(p.requests))\n\tcopy(out, p.requests)\n\treturn out\n}\n\nfunc bootLastUser(req provider.Request) string {\n\tfor i := len(req.Messages) - 1; i >= 0; i-- {\n\t\tif req.Messages[i].Role == provider.RoleUser {\n\t\t\treturn req.Messages[i].Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc subagentRefFromHistory(t *testing.T, msgs []provider.Message) string {\n\tt.Helper()\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, line := range strings.Split(msg.Content, \"\\n\") {\n\t\t\tif strings.HasPrefix(line, \"Subagent reference: \") {\n\t\t\t\treturn strings.TrimSpace(strings.TrimPrefix(line, \"Subagent reference: \"))\n\t\t\t}\n\t\t}\n\t}\n\tt.Fatalf(\"no subagent reference in history: %+v\", msgs)\n\treturn \"\"\n}\n\n// TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath reproduces headless\n// `reasonix run`: a controller built via Build with NO SetSessionPath (exactly\n// what internal/cli.runAgent does) must still be able to run a `task` sub-agent.\n// Before the ephemeral fallback this failed with \"parent session is required\".\nfunc TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterHeadlessTaskTestProvider()\n\tprov := &headlessTaskTestProvider{}\n\tsetHeadlessTaskTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-headless-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// Deliberately NOT calling SetSessionPath — this is the headless run path.\n\tif err := ctrl.Run(context.Background(), \"use a task subagent\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tif got := ctrl.SessionPath(); got != \"\" {\n\t\tt.Fatalf(\"headless run should keep an empty session path, got %q\", got)\n\t}\n\n\tvar toolContent string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolContent += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif strings.Contains(toolContent, \"parent session is required\") {\n\t\tt.Fatalf(\"task subagent failed in headless run mode: %s\", toolContent)\n\t}\n\tif !strings.Contains(toolContent, \"subagent answer\") {\n\t\tt.Fatalf(\"task tool result = %q, want sub-agent answer\", toolContent)\n\t}\n\tif strings.Contains(toolContent, \"Subagent reference\") {\n\t\tt.Fatalf(\"ephemeral headless run should not persist a transcript reference: %s\", toolContent)\n\t}\n}\n\nconst headlessTaskTestProviderKind = \"boot-headless-test\"\n\nvar (\n\theadlessTaskTestProviderOnce sync.Once\n\theadlessTaskTestProviderCurrent *headlessTaskTestProvider\n\theadlessTaskTestProviderMu sync.Mutex\n)\n\nfunc registerHeadlessTaskTestProvider() {\n\theadlessTaskTestProviderOnce.Do(func() {\n\t\tprovider.Register(headlessTaskTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\theadlessTaskTestProviderMu.Lock()\n\t\t\tdefer headlessTaskTestProviderMu.Unlock()\n\t\t\tif headlessTaskTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"headless task test provider is not installed\")\n\t\t\t}\n\t\t\treturn headlessTaskTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setHeadlessTaskTestProvider(t *testing.T, p *headlessTaskTestProvider) {\n\tt.Helper()\n\theadlessTaskTestProviderMu.Lock()\n\theadlessTaskTestProviderCurrent = p\n\theadlessTaskTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\theadlessTaskTestProviderMu.Lock()\n\t\tif headlessTaskTestProviderCurrent == p {\n\t\t\theadlessTaskTestProviderCurrent = nil\n\t\t}\n\t\theadlessTaskTestProviderMu.Unlock()\n\t})\n}\n\ntype headlessTaskTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n}\n\nfunc (p *headlessTaskTestProvider) Name() string { return \"boot-headless-test\" }\n\nfunc (p *headlessTaskTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"task-1\", Name: \"task\", Arguments: `{\"prompt\":\"find callers\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"subagent answer\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent done\"}, {Type: provider.ChunkDone}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\nfunc TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4-flash\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want high from DeepSeek model capability\", got)\n\t}\n\tthinking, ok := gotReq[\"thinking\"].(map[string]any)\n\tif !ok || thinking[\"type\"] != \"enabled\" {\n\t\tt.Fatalf(\"thinking = %#v, want enabled\", gotReq[\"thinking\"])\n\t}\n}\n\nfunc TestBuildHonorsSessionDirOverride(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.SessionDir(); got != sessionDir {\n\t\tt.Fatalf(\"SessionDir() = %q, want override %q\", got, sessionDir)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildTokenFullMatchesDefaultRequestPrefix(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tdefaultReq := firstTokenProfileRequest(t, \"\")\n\tfullReq := firstTokenProfileRequest(t, TokenModeFull)\n\n\tif got, want := systemMessage(defaultReq.Messages), systemMessage(fullReq.Messages); got != want {\n\t\tt.Fatalf(\"explicit full mode changed the system prompt\\n--- default ---\\n%s\\n--- full ---\\n%s\", got, want)\n\t}\n\tif strings.Contains(systemMessage(fullReq.Messages), tokenEconomyPrompt) {\n\t\tt.Fatalf(\"full mode system prompt should not include token economy prompt:\\n%s\", systemMessage(fullReq.Messages))\n\t}\n\tif !strings.Contains(systemMessage(fullReq.Messages), \"# Skills\") || !strings.Contains(systemMessage(fullReq.Messages), \"projskill\") {\n\t\tt.Fatalf(\"full mode should preserve the skills index in the system prompt:\\n%s\", systemMessage(fullReq.Messages))\n\t}\n\tif got, want := toolSchemaNames(fullReq.Tools), toolSchemaNames(defaultReq.Tools); !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"explicit full mode changed tool schema order\\nfull=%v\\ndefault=%v\", got, want)\n\t}\n\tif !reflect.DeepEqual(fullReq.Tools, defaultReq.Tools) {\n\t\tt.Fatalf(\"explicit full mode changed provider-visible tool schemas; names=%v\", toolSchemaNames(fullReq.Tools))\n\t}\n\tif requestHasTool(fullReq, \"connect_tool_source\") {\n\t\tt.Fatalf(\"full mode should not expose economy connector; tools=%v\", toolSchemaNames(fullReq.Tools))\n\t}\n}\n\nfunc TestBuildTokenEconomyStartsWithLeanToolSurface(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\", testutil.Turn{Text: \"done\"})\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n\n[[plugins]]\nname = \"mockmcp\"\ncommand = \"reasonix-missing-mockmcp\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"use the lean surface\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 1 {\n\t\tt.Fatalf(\"requests = %d, want 1\", len(reqs))\n\t}\n\treq := reqs[0]\n\twantTools := []string{\n\t\t\"ask\",\n\t\t\"bash\",\n\t\t\"bash_output\",\n\t\t\"complete_step\",\n\t\t\"connect_tool_source\",\n\t\t\"edit_file\",\n\t\t\"forget\",\n\t\t\"glob\",\n\t\t\"grep\",\n\t\t\"history\",\n\t\t\"kill_shell\",\n\t\t\"ls\",\n\t\t\"memory\",\n\t\t\"move_file\",\n\t\t\"multi_edit\",\n\t\t\"read_file\",\n\t\t\"remember\",\n\t\t\"slash_command\",\n\t\t\"todo_write\",\n\t\t\"wait\",\n\t\t\"write_file\",\n\t}\n\tif got := toolSchemaNames(req.Tools); !reflect.DeepEqual(got, wantTools) {\n\t\tt.Fatalf(\"economy first request tool order changed\\ngot %v\\nwant %v\", got, wantTools)\n\t}\n\tfor _, want := range []string{\"connect_tool_source\", \"read_file\", \"grep\", \"edit_file\", \"bash\", \"slash_command\", \"ask\"} {\n\t\tif !requestHasTool(req, want) {\n\t\t\tt.Fatalf(\"economy first request missing tool %q; tools=%v\", want, toolSchemaNames(req.Tools))\n\t\t}\n\t}\n\tfor _, forbidden := range []string{\n\t\t\"web_fetch\", \"task\", \"run_skill\", \"read_skill\", \"install_skill\", \"install_source\",\n\t\t\"explore\", \"research\", \"review\", \"security_review\",\n\t\t\"lsp_definition\", \"lsp_references\", \"lsp_hover\", \"lsp_diagnostics\",\n\t} {\n\t\tif requestHasTool(req, forbidden) {\n\t\t\tt.Fatalf(\"economy first request should hide %q; tools=%v\", forbidden, toolSchemaNames(req.Tools))\n\t\t}\n\t}\n\tif requestHasToolPrefix(req, \"mcp__mockmcp\") {\n\t\tt.Fatalf(\"economy first request should not expose MCP placeholders; tools=%v\", toolSchemaNames(req.Tools))\n\t}\n\tsys := systemMessage(req.Messages)\n\tif !strings.Contains(sys, tokenEconomyPrompt) {\n\t\tt.Fatalf(\"token economy prompt missing from system message:\\n%s\", sys)\n\t}\n\tif strings.Contains(sys, \"# Skills\") || strings.Contains(sys, \"projskill\") {\n\t\tt.Fatalf(\"skills index should not be in economy system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildTokenEconomyConnectsWebFetchOnDemand(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"fetch later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasTool(reqs[0], \"web_fetch\") {\n\t\tt.Fatalf(\"first request should hide web_fetch; tools=%v\", toolSchemaNames(reqs[0].Tools))\n\t}\n\tif !requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"second request should expose web_fetch after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n}\n\nfunc TestBuildTokenEconomyCodegraphSetsShortDaemonIdleTimeout(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tenvOut := filepath.Join(dir, \"codegraph-idle-env\")\n\tt.Setenv(\"REASONIX_CODEGRAPH_HELPER_ENV_OUT\", envOut)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"codegraph\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`, launcher))\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"enable codegraph later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasToolPrefix(reqs[0], \"mcp__codegraph__\") {\n\t\tt.Fatalf(\"first request should hide codegraph; tools=%v\", toolSchemaNames(reqs[0].Tools))\n\t}\n\tif !requestHasToolPrefix(reqs[1], \"mcp__codegraph__\") {\n\t\tt.Fatalf(\"second request should expose codegraph after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tgot, err := os.ReadFile(envOut)\n\tif err != nil {\n\t\tt.Fatalf(\"read codegraph idle timeout env: %v\", err)\n\t}\n\tif string(got) != codegraph.ReasonixDaemonIdleTimeoutMS {\n\t\tt.Fatalf(\"%s = %q; want %q\", codegraph.DaemonIdleTimeoutEnv, got, codegraph.ReasonixDaemonIdleTimeoutMS)\n\t}\n}\n\nfunc TestBuildTokenEconomyPlanModeCanConnectWebFetch(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tctrl.SetPlanMode(true)\n\tif err := ctrl.Run(context.Background(), \"fetch later while planning\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif !requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"second request should expose web_fetch in plan economy mode; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" && strings.Contains(msg.Content, \"blocked:\") {\n\t\t\tt.Fatalf(\"connect_tool_source should not be blocked in plan mode, got:\\n%s\", msg.Content)\n\t\t}\n\t}\n}\n\nfunc TestBuildTokenEconomyWebFetchConnectorHonorsDisabledBuiltin(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[tools]\nenabled = [\"read_file\", \"grep\"]\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"fetch later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"disabled web_fetch should not be exposed after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tvar toolOutput string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" {\n\t\t\ttoolOutput += msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolOutput, \"web_fetch is disabled by [tools].enabled\") {\n\t\tt.Fatalf(\"connector should explain disabled web_fetch, got:\\n%s\", toolOutput)\n\t}\n}\n\nfunc TestBuildTokenEconomyConnectsSkillsOnDemand(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"skills\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"use skills later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tfor _, name := range []string{\"run_skill\", \"read_skill\", \"explore\"} {\n\t\tif requestHasTool(reqs[0], name) {\n\t\t\tt.Fatalf(\"first request should hide %q; tools=%v\", name, toolSchemaNames(reqs[0].Tools))\n\t\t}\n\t\tif !requestHasTool(reqs[1], name) {\n\t\t\tt.Fatalf(\"second request should expose %q after connect_tool_source; tools=%v\", name, toolSchemaNames(reqs[1].Tools))\n\t\t}\n\t}\n\tvar toolOutput string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" {\n\t\t\ttoolOutput += msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolOutput, \"projskill\") || !strings.Contains(toolOutput, \"# Skills\") {\n\t\tt.Fatalf(\"skills source result should include the skill index, got:\\n%s\", toolOutput)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{})\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"move_file\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(workspace*)\"]\n`)\n\n\tconst rule = \"Bash(go test ./...)\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleCreatesWorkspaceConfigOverUserConfig(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tworkspace := robustTempDir(t)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user)\"]\n`)\n\n\tconst rule = \"Edit(src/app.go)\"\n\tres := rememberPermissionRule(workspace, rule)\n\tif !res.Saved || res.Path != filepath.Join(workspace, \"reasonix.toml\") {\n\t\tt.Fatalf(\"remember result = %+v, want saved to workspace config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule was written to user config: %v\", userCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule missing from project config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user*)\"]\n`)\n\n\tconst rule = \"Bash(go env)\"\n\tres := rememberPermissionRule(\"\", rule)\n\tif !res.Saved || res.Path != userConfig {\n\t\tt.Fatalf(\"remember result = %+v, want saved to user source config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc TestRememberPermissionRuleSkipsRuleCoveredByExistingAllow(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test:*)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test ./...)\")\n\tif res.Saved || res.CoveredBy != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"remember result = %+v, want already covered\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif len(cfg.Permissions.Allow) != 1 || cfg.Permissions.Allow[0] != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"allow rules = %v, want only existing prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRulePrunesNarrowRulesWhenSavingBroaderRule(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test ./...)\", \"Bash(go build ./...)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test:*)\")\n\tif !res.Saved || res.CoveredBy != \"\" {\n\t\tt.Fatalf(\"remember result = %+v, want saved broader rule\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test ./...)\") {\n\t\tt.Fatalf(\"narrow go test rule should be pruned: %v\", cfg.Permissions.Allow)\n\t}\n\tif !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go build ./...)\") || !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test:*)\") {\n\t\tt.Fatalf(\"allow rules = %v, want unrelated exact plus prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuiltInMCPsYieldToExtraPluginNames(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, []string{\"time\", \"context7\"}, pluginSpecNames([]plugin.Spec{{Name: \"time\"}})...)\n\tif len(got) != 1 || got[0].Name != \"context7\" {\n\t\tt.Fatalf(\"built-in MCP entries with extra time = %+v, want only context7\", got)\n\t}\n}\n\nfunc TestBuiltInMCPDefaultsEnableOnlyTime(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, config.Default().BuiltInMCP.EnabledNames())\n\tif len(got) != 1 || got[0].Name != \"time\" {\n\t\tt.Fatalf(\"default built-in MCP entries = %+v, want only time\", got)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildCodegraphSetsShortDaemonIdleTimeout(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tenvOut := filepath.Join(dir, \"codegraph-idle-env\")\n\tt.Setenv(\"REASONIX_CODEGRAPH_HELPER_ENV_OUT\", envOut)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tdeadline := time.Now().Add(5 * time.Second)\n\tvar got []byte\n\tfor {\n\t\tgot, err = os.ReadFile(envOut)\n\t\tif err == nil && string(got) == codegraph.ReasonixDaemonIdleTimeoutMS {\n\t\t\tbreak\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"codegraph helper idle timeout env = %q, want %q (read error: %v)\", got, codegraph.ReasonixDaemonIdleTimeoutMS, err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc TestBuildWarmCodegraphIgnoresLegacyEagerTier(t *testing.T) {\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"fake launcher is a POSIX-sh script\")\n\t}\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.Mkdir(filepath.Join(dir, \".codegraph\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tlauncher := filepath.Join(dir, \"slow-codegraph\")\n\twriteFile(t, dir, \"slow-codegraph\", \"#!/bin/sh\\nif [ \\\"$1\\\" = serve ]; then sleep 5; fi\\n\")\n\tif err := os.Chmod(launcher, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"eager\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not block on warm codegraph with legacy eager tier: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildDefaultsToNearestGitRoot(t *testing.T) {\n\tisolateConfigHome(t)\n\troot := robustTempDir(t)\n\tif err := os.Mkdir(filepath.Join(root, \".git\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsubdir := filepath.Join(root, \"cmd\", \"tool\")\n\tif err := os.MkdirAll(subdir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\twriteFile(t, root, \"reasonix.toml\", `\ndefault_model = \"root-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"root-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\tt.Chdir(subdir)\n\n\tctrl, err := Build(context.Background(), Options{Model: \"root-model\"})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should load config from nearest git root: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\tif len(os.Args) >= 2 && os.Args[1] == \"serve\" {\n\t\tif out := os.Getenv(\"REASONIX_CODEGRAPH_HELPER_ENV_OUT\"); out != \"\" {\n\t\t\t_ = os.WriteFile(out, []byte(os.Getenv(\"CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS\")), 0o644)\n\t\t}\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "375a0fcf2ab86cc009dddefa01511231d5ce5727": {"t": 1785750696.7406082, "n": 70695, "h": "ea0fec2b9b0cb7ca", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/agent/testutil\"\n\t\"reasonix/internal/builtinmcp\"\n\t\"reasonix/internal/codegraph\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestBuildRegistersUsableHistoryAndMemoryRetrievalTools(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-retrieval-tool-test\"\nmodel = \"x\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"sessions\")\n\tif err := os.MkdirAll(sessionDir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tpast := agent.NewSession(\"\")\n\tpast.Add(provider.Message{Role: provider.RoleUser, Content: \"Should the history layer use vector embeddings?\"})\n\tpast.Add(provider.Message{Role: provider.RoleAssistant, Content: \"Decision: port lightweight BM25 history retrieval without a vector database.\"})\n\tif err := past.Save(filepath.Join(sessionDir, \"past.jsonl\")); err != nil {\n\t\tt.Fatalf(\"save past session: %v\", err)\n\t}\n\n\tstore := memory.StoreFor(config.MemoryUserDir(), dir)\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"synthesis-cache-policy\",\n\t\tDescription: \"Stable conclusions should be reused from memory\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t}); err != nil {\n\t\tt.Fatalf(\"save memory: %v\", err)\n\t}\n\n\tregisterBootRetrievalToolTestProvider()\n\tprov := testutil.NewMock(\"boot-retrieval-tool-test\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"history-1\", Name: \"history\", Arguments: `{\"operation\":\"search\",\"query\":\"BM25 vector database\",\"scope\":\"project\",\"limit\":5}`},\n\t\t\t{ID: \"memory-1\", Name: \"memory\", Arguments: `{\"operation\":\"search\",\"query\":\"synthesis cache stable conclusion\",\"limit\":5}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootRetrievalToolTestProvider(t, prov)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tfor _, forbidden := range []string{\n\t\t\"Decision: port lightweight BM25 history retrieval without a vector database.\",\n\t\t\"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t} {\n\t\tif strings.Contains(sys, forbidden) {\n\t\t\tt.Fatalf(\"retrieval content should stay behind on-demand tools, not enter the cache-stable system prompt:\\n%s\", sys)\n\t\t}\n\t}\n\n\tif err := ctrl.Run(context.Background(), \"recover past context\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) == 0 {\n\t\tt.Fatal(\"provider received no requests\")\n\t}\n\tfor _, want := range []string{\"history\", \"memory\", \"remember\", \"forget\"} {\n\t\tif !requestHasTool(reqs[0], want) {\n\t\t\tt.Fatalf(\"first request missing tool %q; tools=%v\", want, toolSchemaNames(reqs[0].Tools))\n\t\t}\n\t}\n\tassertToolOrder(t, reqs[0].Tools, []string{\"forget\", \"history\", \"memory\", \"remember\"})\n\n\ttoolResults := map[string]string{}\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolResults[msg.Name] += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolResults[\"history\"], \"port lightweight BM25 history retrieval\") {\n\t\tt.Fatalf(\"history tool result did not include saved session decision:\\n%s\", toolResults[\"history\"])\n\t}\n\tif !strings.Contains(toolResults[\"memory\"], \"synthesis-cache-policy\") ||\n\t\t!strings.Contains(toolResults[\"memory\"], \"stable conclusion\") {\n\t\tt.Fatalf(\"memory tool result did not include saved memory:\\n%s\", toolResults[\"memory\"])\n\t}\n}\n\nconst bootRetrievalToolTestProviderKind = \"boot-retrieval-tool-test\"\n\nvar (\n\tbootRetrievalToolTestProviderOnce sync.Once\n\tbootRetrievalToolTestProviderCurrent *testutil.MockProvider\n\tbootRetrievalToolTestProviderMu sync.Mutex\n)\n\nfunc registerBootRetrievalToolTestProvider() {\n\tbootRetrievalToolTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootRetrievalToolTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\t\tdefer bootRetrievalToolTestProviderMu.Unlock()\n\t\t\tif bootRetrievalToolTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot retrieval tool test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootRetrievalToolTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootRetrievalToolTestProvider(t *testing.T, p *testutil.MockProvider) {\n\tt.Helper()\n\tbootRetrievalToolTestProviderMu.Lock()\n\tbootRetrievalToolTestProviderCurrent = p\n\tbootRetrievalToolTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\tif bootRetrievalToolTestProviderCurrent == p {\n\t\t\tbootRetrievalToolTestProviderCurrent = nil\n\t\t}\n\t\tbootRetrievalToolTestProviderMu.Unlock()\n\t})\n}\n\nconst bootTokenProfileTestProviderKind = \"boot-token-profile-test\"\n\nvar (\n\tbootTokenProfileTestProviderOnce sync.Once\n\tbootTokenProfileTestProviderCurrent *testutil.MockProvider\n\tbootTokenProfileTestProviderMu sync.Mutex\n)\n\nfunc registerBootTokenProfileTestProvider() {\n\tbootTokenProfileTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootTokenProfileTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootTokenProfileTestProviderMu.Lock()\n\t\t\tdefer bootTokenProfileTestProviderMu.Unlock()\n\t\t\tif bootTokenProfileTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot token profile test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootTokenProfileTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootTokenProfileTestProvider(t *testing.T, p *testutil.MockProvider) {\n\tt.Helper()\n\tbootTokenProfileTestProviderMu.Lock()\n\tbootTokenProfileTestProviderCurrent = p\n\tbootTokenProfileTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootTokenProfileTestProviderMu.Lock()\n\t\tif bootTokenProfileTestProviderCurrent == p {\n\t\t\tbootTokenProfileTestProviderCurrent = nil\n\t\t}\n\t\tbootTokenProfileTestProviderMu.Unlock()\n\t})\n}\n\nfunc requestHasTool(req provider.Request, name string) bool {\n\tfor _, schema := range req.Tools {\n\t\tif schema.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc requestHasToolPrefix(req provider.Request, prefix string) bool {\n\tfor _, schema := range req.Tools {\n\t\tif strings.HasPrefix(schema.Name, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc toolSchemaNames(tools []provider.ToolSchema) []string {\n\tnames := make([]string, 0, len(tools))\n\tfor _, schema := range tools {\n\t\tnames = append(names, schema.Name)\n\t}\n\treturn names\n}\n\nfunc assertToolOrder(t *testing.T, tools []provider.ToolSchema, want []string) {\n\tt.Helper()\n\tnames := toolSchemaNames(tools)\n\tnext := 0\n\tfor _, name := range names {\n\t\tif next < len(want) && name == want[next] {\n\t\t\tnext++\n\t\t}\n\t}\n\tif next != len(want) {\n\t\tt.Fatalf(\"tool order changed; provider-visible tool schema order affects prompt-cache shape.\\nwant subsequence: %v\\n got: %v\", want, names)\n\t}\n}\n\nfunc firstTokenProfileRequest(t *testing.T, tokenMode string) provider.Request {\n\tt.Helper()\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-profile\", testutil.Turn{Text: \"done\"})\n\tsetBootTokenProfileTestProvider(t, prov)\n\n\topts := Options{Sink: event.Discard}\n\tif tokenMode != \"\" {\n\t\topts.TokenMode = tokenMode\n\t}\n\tctrl, err := Build(context.Background(), opts)\n\tif err != nil {\n\t\tt.Fatalf(\"Build(%q): %v\", tokenMode, err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"capture request prefix\"); err != nil {\n\t\tt.Fatalf(\"Run(%q): %v\", tokenMode, err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 1 {\n\t\tt.Fatalf(\"requests(%q) = %d, want 1\", tokenMode, len(reqs))\n\t}\n\treturn reqs[0]\n}\n\nfunc TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"first Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\tprov.setContinueRef(ref)\n\n\tif err := ctrl.Run(context.Background(), \"continue review\"); err != nil {\n\t\tt.Fatalf(\"second Run: %v\", err)\n\t}\n\tstore := agent.NewSubagentStore(filepath.Join(config.SessionDir(), \"subagents\"))\n\tmeta, err := store.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta: %v\", err)\n\t}\n\tif meta.Status != agent.SubagentFailed {\n\t\tt.Fatalf(\"status = %q, want failed\", meta.Status)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tsess, err := agent.LoadSession(filepath.Join(config.SessionDir(), \"subagents\", ref+\".jsonl\"))\n\tif err != nil {\n\t\tt.Fatalf(\"LoadSession: %v\", err)\n\t}\n\tmsgs := sess.Snapshot()\n\tif len(msgs) != 4 || msgs[1].Content != \"first skill task\" || msgs[2].Content != \"first skill answer\" || msgs[3].Content != \"second skill task\" {\n\t\tt.Fatalf(\"failed skill transcript = %+v, want first task/answer plus second task\", msgs)\n\t}\n}\n\nfunc TestBuildSubagentStoreHonorsSessionDirOverride(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\n\toverrideStore := agent.NewSubagentStore(filepath.Join(sessionDir, \"subagents\"))\n\tmeta, err := overrideStore.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta from override dir: %v\", err)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \"subagents\", ref+\".meta.json\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"subagent metadata should not be written to global session dir, stat err = %v\", err)\n\t}\n}\n\nfunc TestBuildSubagentSkillUsesLiveReasoningLanguage(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\nreasoning_language = \"zh\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tctrl.SetReasoningLanguage(\"auto\")\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.requestsSnapshot()\n\tif len(reqs) < 2 {\n\t\tt.Fatalf(\"provider requests = %d, want parent request plus skill subagent request\", len(reqs))\n\t}\n\tif got := bootLastUser(reqs[1]); strings.Contains(got, \"\") {\n\t\tt.Fatalf(\"skill subagent kept stale boot-time reasoning language after live auto update: %q\", got)\n\t}\n\tif got := bootLastUser(reqs[1]); got != \"first skill task\" {\n\t\tt.Fatalf(\"skill subagent user prompt = %q, want first skill task\", got)\n\t}\n}\n\nconst bootSubagentTestProviderKind = \"boot-subagent-test\"\n\nvar (\n\tbootSubagentTestProviderOnce sync.Once\n\tbootSubagentTestProviderCurrent *bootSubagentTestProvider\n\tbootSubagentTestProviderMu sync.Mutex\n)\n\nfunc registerBootSubagentTestProvider() {\n\tbootSubagentTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootSubagentTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootSubagentTestProviderMu.Lock()\n\t\t\tdefer bootSubagentTestProviderMu.Unlock()\n\t\t\tif bootSubagentTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot subagent test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootSubagentTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootSubagentTestProvider(t *testing.T, p *bootSubagentTestProvider) {\n\tt.Helper()\n\tbootSubagentTestProviderMu.Lock()\n\tbootSubagentTestProviderCurrent = p\n\tbootSubagentTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootSubagentTestProviderMu.Lock()\n\t\tif bootSubagentTestProviderCurrent == p {\n\t\t\tbootSubagentTestProviderCurrent = nil\n\t\t}\n\t\tbootSubagentTestProviderMu.Unlock()\n\t})\n}\n\ntype bootSubagentTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n\tcontinueRef string\n\trequests []provider.Request\n}\n\nfunc (p *bootSubagentTestProvider) Name() string { return \"boot-subagent-test\" }\n\nfunc (p *bootSubagentTestProvider) setContinueRef(ref string) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tp.continueRef = ref\n}\n\nfunc (p *bootSubagentTestProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tref := p.continueRef\n\tp.requests = append(p.requests, req)\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-1\", Name: \"review\", Arguments: `{\"task\":\"first skill task\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"first skill answer\"}, {Type: provider.ChunkDone}}\n\tcase 2:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent first done\"}, {Type: provider.ChunkDone}}\n\tcase 3:\n\t\targs, _ := json.Marshal(map[string]string{\"task\": \"second skill task\", \"continue_from\": ref})\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-2\", Name: \"review\", Arguments: string(args)}}}\n\tcase 4:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: errors.New(\"subagent skill failed\")}}\n\tcase 5:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent second done\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf(\"unexpected provider call %d\", call)}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc (p *bootSubagentTestProvider) requestsSnapshot() []provider.Request {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tout := make([]provider.Request, len(p.requests))\n\tcopy(out, p.requests)\n\treturn out\n}\n\nfunc bootLastUser(req provider.Request) string {\n\tfor i := len(req.Messages) - 1; i >= 0; i-- {\n\t\tif req.Messages[i].Role == provider.RoleUser {\n\t\t\treturn req.Messages[i].Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc subagentRefFromHistory(t *testing.T, msgs []provider.Message) string {\n\tt.Helper()\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, line := range strings.Split(msg.Content, \"\\n\") {\n\t\t\tif strings.HasPrefix(line, \"Subagent reference: \") {\n\t\t\t\treturn strings.TrimSpace(strings.TrimPrefix(line, \"Subagent reference: \"))\n\t\t\t}\n\t\t}\n\t}\n\tt.Fatalf(\"no subagent reference in history: %+v\", msgs)\n\treturn \"\"\n}\n\n// TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath reproduces headless\n// `reasonix run`: a controller built via Build with NO SetSessionPath (exactly\n// what internal/cli.runAgent does) must still be able to run a `task` sub-agent.\n// Before the ephemeral fallback this failed with \"parent session is required\".\nfunc TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterHeadlessTaskTestProvider()\n\tprov := &headlessTaskTestProvider{}\n\tsetHeadlessTaskTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-headless-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// Deliberately NOT calling SetSessionPath — this is the headless run path.\n\tif err := ctrl.Run(context.Background(), \"use a task subagent\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tif got := ctrl.SessionPath(); got != \"\" {\n\t\tt.Fatalf(\"headless run should keep an empty session path, got %q\", got)\n\t}\n\n\tvar toolContent string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolContent += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif strings.Contains(toolContent, \"parent session is required\") {\n\t\tt.Fatalf(\"task subagent failed in headless run mode: %s\", toolContent)\n\t}\n\tif !strings.Contains(toolContent, \"subagent answer\") {\n\t\tt.Fatalf(\"task tool result = %q, want sub-agent answer\", toolContent)\n\t}\n\tif strings.Contains(toolContent, \"Subagent reference\") {\n\t\tt.Fatalf(\"ephemeral headless run should not persist a transcript reference: %s\", toolContent)\n\t}\n}\n\nconst headlessTaskTestProviderKind = \"boot-headless-test\"\n\nvar (\n\theadlessTaskTestProviderOnce sync.Once\n\theadlessTaskTestProviderCurrent *headlessTaskTestProvider\n\theadlessTaskTestProviderMu sync.Mutex\n)\n\nfunc registerHeadlessTaskTestProvider() {\n\theadlessTaskTestProviderOnce.Do(func() {\n\t\tprovider.Register(headlessTaskTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\theadlessTaskTestProviderMu.Lock()\n\t\t\tdefer headlessTaskTestProviderMu.Unlock()\n\t\t\tif headlessTaskTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"headless task test provider is not installed\")\n\t\t\t}\n\t\t\treturn headlessTaskTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setHeadlessTaskTestProvider(t *testing.T, p *headlessTaskTestProvider) {\n\tt.Helper()\n\theadlessTaskTestProviderMu.Lock()\n\theadlessTaskTestProviderCurrent = p\n\theadlessTaskTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\theadlessTaskTestProviderMu.Lock()\n\t\tif headlessTaskTestProviderCurrent == p {\n\t\t\theadlessTaskTestProviderCurrent = nil\n\t\t}\n\t\theadlessTaskTestProviderMu.Unlock()\n\t})\n}\n\ntype headlessTaskTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n}\n\nfunc (p *headlessTaskTestProvider) Name() string { return \"boot-headless-test\" }\n\nfunc (p *headlessTaskTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"task-1\", Name: \"task\", Arguments: `{\"prompt\":\"find callers\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"subagent answer\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent done\"}, {Type: provider.ChunkDone}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\nfunc TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4-flash\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want high from DeepSeek model capability\", got)\n\t}\n\tthinking, ok := gotReq[\"thinking\"].(map[string]any)\n\tif !ok || thinking[\"type\"] != \"enabled\" {\n\t\tt.Fatalf(\"thinking = %#v, want enabled\", gotReq[\"thinking\"])\n\t}\n}\n\nfunc TestBuildHonorsSessionDirOverride(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.SessionDir(); got != sessionDir {\n\t\tt.Fatalf(\"SessionDir() = %q, want override %q\", got, sessionDir)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildTokenFullMatchesDefaultRequestPrefix(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tdefaultReq := firstTokenProfileRequest(t, \"\")\n\tfullReq := firstTokenProfileRequest(t, TokenModeFull)\n\n\tif got, want := systemMessage(defaultReq.Messages), systemMessage(fullReq.Messages); got != want {\n\t\tt.Fatalf(\"explicit full mode changed the system prompt\\n--- default ---\\n%s\\n--- full ---\\n%s\", got, want)\n\t}\n\tif strings.Contains(systemMessage(fullReq.Messages), tokenEconomyPrompt) {\n\t\tt.Fatalf(\"full mode system prompt should not include token economy prompt:\\n%s\", systemMessage(fullReq.Messages))\n\t}\n\tif !strings.Contains(systemMessage(fullReq.Messages), \"# Skills\") || !strings.Contains(systemMessage(fullReq.Messages), \"projskill\") {\n\t\tt.Fatalf(\"full mode should preserve the skills index in the system prompt:\\n%s\", systemMessage(fullReq.Messages))\n\t}\n\tif got, want := toolSchemaNames(fullReq.Tools), toolSchemaNames(defaultReq.Tools); !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"explicit full mode changed tool schema order\\nfull=%v\\ndefault=%v\", got, want)\n\t}\n\tif !reflect.DeepEqual(fullReq.Tools, defaultReq.Tools) {\n\t\tt.Fatalf(\"explicit full mode changed provider-visible tool schemas; names=%v\", toolSchemaNames(fullReq.Tools))\n\t}\n\tif requestHasTool(fullReq, \"connect_tool_source\") {\n\t\tt.Fatalf(\"full mode should not expose economy connector; tools=%v\", toolSchemaNames(fullReq.Tools))\n\t}\n}\n\nfunc TestBuildTokenEconomyStartsWithLeanToolSurface(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\", testutil.Turn{Text: \"done\"})\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n\n[[plugins]]\nname = \"mockmcp\"\ncommand = \"reasonix-missing-mockmcp\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"use the lean surface\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 1 {\n\t\tt.Fatalf(\"requests = %d, want 1\", len(reqs))\n\t}\n\treq := reqs[0]\n\twantTools := []string{\n\t\t\"ask\",\n\t\t\"bash\",\n\t\t\"bash_output\",\n\t\t\"complete_step\",\n\t\t\"connect_tool_source\",\n\t\t\"edit_file\",\n\t\t\"forget\",\n\t\t\"glob\",\n\t\t\"grep\",\n\t\t\"history\",\n\t\t\"kill_shell\",\n\t\t\"ls\",\n\t\t\"memory\",\n\t\t\"move_file\",\n\t\t\"multi_edit\",\n\t\t\"read_file\",\n\t\t\"remember\",\n\t\t\"slash_command\",\n\t\t\"todo_write\",\n\t\t\"wait\",\n\t\t\"write_file\",\n\t}\n\tif got := toolSchemaNames(req.Tools); !reflect.DeepEqual(got, wantTools) {\n\t\tt.Fatalf(\"economy first request tool order changed\\ngot %v\\nwant %v\", got, wantTools)\n\t}\n\tfor _, want := range []string{\"connect_tool_source\", \"read_file\", \"grep\", \"edit_file\", \"bash\", \"slash_command\", \"ask\"} {\n\t\tif !requestHasTool(req, want) {\n\t\t\tt.Fatalf(\"economy first request missing tool %q; tools=%v\", want, toolSchemaNames(req.Tools))\n\t\t}\n\t}\n\tfor _, forbidden := range []string{\n\t\t\"web_fetch\", \"task\", \"run_skill\", \"read_skill\", \"install_skill\", \"install_source\",\n\t\t\"explore\", \"research\", \"review\", \"security_review\",\n\t\t\"lsp_definition\", \"lsp_references\", \"lsp_hover\", \"lsp_diagnostics\",\n\t} {\n\t\tif requestHasTool(req, forbidden) {\n\t\t\tt.Fatalf(\"economy first request should hide %q; tools=%v\", forbidden, toolSchemaNames(req.Tools))\n\t\t}\n\t}\n\tif requestHasToolPrefix(req, \"mcp__mockmcp\") {\n\t\tt.Fatalf(\"economy first request should not expose MCP placeholders; tools=%v\", toolSchemaNames(req.Tools))\n\t}\n\tsys := systemMessage(req.Messages)\n\tif !strings.Contains(sys, tokenEconomyPrompt) {\n\t\tt.Fatalf(\"token economy prompt missing from system message:\\n%s\", sys)\n\t}\n\tif strings.Contains(sys, \"# Skills\") || strings.Contains(sys, \"projskill\") {\n\t\tt.Fatalf(\"skills index should not be in economy system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildTokenEconomyConnectsWebFetchOnDemand(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"fetch later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasTool(reqs[0], \"web_fetch\") {\n\t\tt.Fatalf(\"first request should hide web_fetch; tools=%v\", toolSchemaNames(reqs[0].Tools))\n\t}\n\tif !requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"second request should expose web_fetch after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n}\n\nfunc TestBuildTokenEconomyCodegraphSetsShortDaemonIdleTimeout(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tenvOut := filepath.Join(dir, \"codegraph-idle-env\")\n\tt.Setenv(\"REASONIX_CODEGRAPH_HELPER_ENV_OUT\", envOut)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"codegraph\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`, launcher))\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"enable codegraph later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasToolPrefix(reqs[0], \"mcp__codegraph__\") {\n\t\tt.Fatalf(\"first request should hide codegraph; tools=%v\", toolSchemaNames(reqs[0].Tools))\n\t}\n\tif !requestHasToolPrefix(reqs[1], \"mcp__codegraph__\") {\n\t\tt.Fatalf(\"second request should expose codegraph after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tgot, err := os.ReadFile(envOut)\n\tif err != nil {\n\t\tt.Fatalf(\"read codegraph idle timeout env: %v\", err)\n\t}\n\tif string(got) != codegraph.ReasonixDaemonIdleTimeoutMS {\n\t\tt.Fatalf(\"%s = %q; want %q\", codegraph.DaemonIdleTimeoutEnv, got, codegraph.ReasonixDaemonIdleTimeoutMS)\n\t}\n}\n\nfunc TestBuildTokenEconomyPlanModeCanConnectWebFetch(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tctrl.SetPlanMode(true)\n\tif err := ctrl.Run(context.Background(), \"fetch later while planning\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif !requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"second request should expose web_fetch in plan economy mode; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" && strings.Contains(msg.Content, \"blocked:\") {\n\t\t\tt.Fatalf(\"connect_tool_source should not be blocked in plan mode, got:\\n%s\", msg.Content)\n\t\t}\n\t}\n}\n\nfunc TestBuildTokenEconomyWebFetchConnectorHonorsDisabledBuiltin(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[tools]\nenabled = [\"read_file\", \"grep\"]\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"fetch later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"disabled web_fetch should not be exposed after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tvar toolOutput string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" {\n\t\t\ttoolOutput += msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolOutput, \"web_fetch is disabled by [tools].enabled\") {\n\t\tt.Fatalf(\"connector should explain disabled web_fetch, got:\\n%s\", toolOutput)\n\t}\n}\n\nfunc TestBuildTokenEconomyConnectsSkillsOnDemand(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"skills\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"use skills later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tfor _, name := range []string{\"run_skill\", \"read_skill\", \"explore\"} {\n\t\tif requestHasTool(reqs[0], name) {\n\t\t\tt.Fatalf(\"first request should hide %q; tools=%v\", name, toolSchemaNames(reqs[0].Tools))\n\t\t}\n\t\tif !requestHasTool(reqs[1], name) {\n\t\t\tt.Fatalf(\"second request should expose %q after connect_tool_source; tools=%v\", name, toolSchemaNames(reqs[1].Tools))\n\t\t}\n\t}\n\tvar toolOutput string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" {\n\t\t\ttoolOutput += msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolOutput, \"projskill\") || !strings.Contains(toolOutput, \"# Skills\") {\n\t\tt.Fatalf(\"skills source result should include the skill index, got:\\n%s\", toolOutput)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{})\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"move_file\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(workspace*)\"]\n`)\n\n\tconst rule = \"Bash(go test ./...)\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleCreatesWorkspaceConfigOverUserConfig(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tworkspace := robustTempDir(t)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user)\"]\n`)\n\n\tconst rule = \"Edit(src/app.go)\"\n\tres := rememberPermissionRule(workspace, rule)\n\tif !res.Saved || res.Path != filepath.Join(workspace, \"reasonix.toml\") {\n\t\tt.Fatalf(\"remember result = %+v, want saved to workspace config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule was written to user config: %v\", userCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule missing from project config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user*)\"]\n`)\n\n\tconst rule = \"Bash(go env)\"\n\tres := rememberPermissionRule(\"\", rule)\n\tif !res.Saved || res.Path != userConfig {\n\t\tt.Fatalf(\"remember result = %+v, want saved to user source config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc TestRememberPermissionRuleSkipsRuleCoveredByExistingAllow(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test:*)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test ./...)\")\n\tif res.Saved || res.CoveredBy != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"remember result = %+v, want already covered\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif len(cfg.Permissions.Allow) != 1 || cfg.Permissions.Allow[0] != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"allow rules = %v, want only existing prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRulePrunesNarrowRulesWhenSavingBroaderRule(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test ./...)\", \"Bash(go build ./...)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test:*)\")\n\tif !res.Saved || res.CoveredBy != \"\" {\n\t\tt.Fatalf(\"remember result = %+v, want saved broader rule\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test ./...)\") {\n\t\tt.Fatalf(\"narrow go test rule should be pruned: %v\", cfg.Permissions.Allow)\n\t}\n\tif !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go build ./...)\") || !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test:*)\") {\n\t\tt.Fatalf(\"allow rules = %v, want unrelated exact plus prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuiltInMCPsYieldToExtraPluginNames(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, []string{\"time\", \"context7\"}, pluginSpecNames([]plugin.Spec{{Name: \"time\"}})...)\n\tif len(got) != 1 || got[0].Name != \"context7\" {\n\t\tt.Fatalf(\"built-in MCP entries with extra time = %+v, want only context7\", got)\n\t}\n}\n\nfunc TestBuiltInMCPDefaultsEnableOnlyTime(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, config.Default().BuiltInMCP.EnabledNames())\n\tif len(got) != 1 || got[0].Name != \"time\" {\n\t\tt.Fatalf(\"default built-in MCP entries = %+v, want only time\", got)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildCodegraphSetsShortDaemonIdleTimeout(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tenvOut := filepath.Join(dir, \"codegraph-idle-env\")\n\tt.Setenv(\"REASONIX_CODEGRAPH_HELPER_ENV_OUT\", envOut)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tdeadline := time.Now().Add(5 * time.Second)\n\tvar got []byte\n\tfor {\n\t\tgot, err = os.ReadFile(envOut)\n\t\tif err == nil && string(got) == codegraph.ReasonixDaemonIdleTimeoutMS {\n\t\t\tbreak\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"codegraph helper idle timeout env = %q, want %q (read error: %v)\", got, codegraph.ReasonixDaemonIdleTimeoutMS, err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\nfunc TestBuildWarmCodegraphIgnoresLegacyEagerTier(t *testing.T) {\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"fake launcher is a POSIX-sh script\")\n\t}\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.Mkdir(filepath.Join(dir, \".codegraph\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tlauncher := filepath.Join(dir, \"slow-codegraph\")\n\twriteFile(t, dir, \"slow-codegraph\", \"#!/bin/sh\\nif [ \\\"$1\\\" = serve ]; then sleep 5; fi\\n\")\n\tif err := os.Chmod(launcher, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"eager\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not block on warm codegraph with legacy eager tier: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildDefaultsToNearestGitRoot(t *testing.T) {\n\tisolateConfigHome(t)\n\troot := robustTempDir(t)\n\tif err := os.Mkdir(filepath.Join(root, \".git\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsubdir := filepath.Join(root, \"cmd\", \"tool\")\n\tif err := os.MkdirAll(subdir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\twriteFile(t, root, \"reasonix.toml\", `\ndefault_model = \"root-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"root-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\tt.Chdir(subdir)\n\n\tctrl, err := Build(context.Background(), Options{Model: \"root-model\"})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should load config from nearest git root: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\tif len(os.Args) >= 2 && os.Args[1] == \"serve\" {\n\t\tif out := os.Getenv(\"REASONIX_CODEGRAPH_HELPER_ENV_OUT\"); out != \"\" {\n\t\t\t_ = os.WriteFile(out, []byte(os.Getenv(\"CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS\")), 0o644)\n\t\t}\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "f5698cda8c545120464acfd1a85ff107a6188b0c": {"t": 1785750698.1748722, "n": 70752, "h": "e68ad1f725b910d3", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/agent/testutil\"\n\t\"reasonix/internal/builtinmcp\"\n\t\"reasonix/internal/codegraph\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestBuildRegistersUsableHistoryAndMemoryRetrievalTools(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-retrieval-tool-test\"\nmodel = \"x\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"sessions\")\n\tif err := os.MkdirAll(sessionDir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tpast := agent.NewSession(\"\")\n\tpast.Add(provider.Message{Role: provider.RoleUser, Content: \"Should the history layer use vector embeddings?\"})\n\tpast.Add(provider.Message{Role: provider.RoleAssistant, Content: \"Decision: port lightweight BM25 history retrieval without a vector database.\"})\n\tif err := past.Save(filepath.Join(sessionDir, \"past.jsonl\")); err != nil {\n\t\tt.Fatalf(\"save past session: %v\", err)\n\t}\n\n\tstore := memory.StoreFor(config.MemoryUserDir(), dir)\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"synthesis-cache-policy\",\n\t\tDescription: \"Stable conclusions should be reused from memory\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t}); err != nil {\n\t\tt.Fatalf(\"save memory: %v\", err)\n\t}\n\n\tregisterBootRetrievalToolTestProvider()\n\tprov := testutil.NewMock(\"boot-retrieval-tool-test\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"history-1\", Name: \"history\", Arguments: `{\"operation\":\"search\",\"query\":\"BM25 vector database\",\"scope\":\"project\",\"limit\":5}`},\n\t\t\t{ID: \"memory-1\", Name: \"memory\", Arguments: `{\"operation\":\"search\",\"query\":\"synthesis cache stable conclusion\",\"limit\":5}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootRetrievalToolTestProvider(t, prov)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tfor _, forbidden := range []string{\n\t\t\"Decision: port lightweight BM25 history retrieval without a vector database.\",\n\t\t\"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t} {\n\t\tif strings.Contains(sys, forbidden) {\n\t\t\tt.Fatalf(\"retrieval content should stay behind on-demand tools, not enter the cache-stable system prompt:\\n%s\", sys)\n\t\t}\n\t}\n\n\tif err := ctrl.Run(context.Background(), \"recover past context\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) == 0 {\n\t\tt.Fatal(\"provider received no requests\")\n\t}\n\tfor _, want := range []string{\"history\", \"memory\", \"remember\", \"forget\"} {\n\t\tif !requestHasTool(reqs[0], want) {\n\t\t\tt.Fatalf(\"first request missing tool %q; tools=%v\", want, toolSchemaNames(reqs[0].Tools))\n\t\t}\n\t}\n\tassertToolOrder(t, reqs[0].Tools, []string{\"forget\", \"history\", \"memory\", \"remember\"})\n\n\ttoolResults := map[string]string{}\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolResults[msg.Name] += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolResults[\"history\"], \"port lightweight BM25 history retrieval\") {\n\t\tt.Fatalf(\"history tool result did not include saved session decision:\\n%s\", toolResults[\"history\"])\n\t}\n\tif !strings.Contains(toolResults[\"memory\"], \"synthesis-cache-policy\") ||\n\t\t!strings.Contains(toolResults[\"memory\"], \"stable conclusion\") {\n\t\tt.Fatalf(\"memory tool result did not include saved memory:\\n%s\", toolResults[\"memory\"])\n\t}\n}\n\nconst bootRetrievalToolTestProviderKind = \"boot-retrieval-tool-test\"\n\nvar (\n\tbootRetrievalToolTestProviderOnce sync.Once\n\tbootRetrievalToolTestProviderCurrent *testutil.MockProvider\n\tbootRetrievalToolTestProviderMu sync.Mutex\n)\n\nfunc registerBootRetrievalToolTestProvider() {\n\tbootRetrievalToolTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootRetrievalToolTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\t\tdefer bootRetrievalToolTestProviderMu.Unlock()\n\t\t\tif bootRetrievalToolTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot retrieval tool test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootRetrievalToolTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootRetrievalToolTestProvider(t *testing.T, p *testutil.MockProvider) {\n\tt.Helper()\n\tbootRetrievalToolTestProviderMu.Lock()\n\tbootRetrievalToolTestProviderCurrent = p\n\tbootRetrievalToolTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\tif bootRetrievalToolTestProviderCurrent == p {\n\t\t\tbootRetrievalToolTestProviderCurrent = nil\n\t\t}\n\t\tbootRetrievalToolTestProviderMu.Unlock()\n\t})\n}\n\nconst bootTokenProfileTestProviderKind = \"boot-token-profile-test\"\n\nvar (\n\tbootTokenProfileTestProviderOnce sync.Once\n\tbootTokenProfileTestProviderCurrent *testutil.MockProvider\n\tbootTokenProfileTestProviderMu sync.Mutex\n)\n\nfunc registerBootTokenProfileTestProvider() {\n\tbootTokenProfileTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootTokenProfileTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootTokenProfileTestProviderMu.Lock()\n\t\t\tdefer bootTokenProfileTestProviderMu.Unlock()\n\t\t\tif bootTokenProfileTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot token profile test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootTokenProfileTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootTokenProfileTestProvider(t *testing.T, p *testutil.MockProvider) {\n\tt.Helper()\n\tbootTokenProfileTestProviderMu.Lock()\n\tbootTokenProfileTestProviderCurrent = p\n\tbootTokenProfileTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootTokenProfileTestProviderMu.Lock()\n\t\tif bootTokenProfileTestProviderCurrent == p {\n\t\t\tbootTokenProfileTestProviderCurrent = nil\n\t\t}\n\t\tbootTokenProfileTestProviderMu.Unlock()\n\t})\n}\n\nfunc requestHasTool(req provider.Request, name string) bool {\n\tfor _, schema := range req.Tools {\n\t\tif schema.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc requestHasToolPrefix(req provider.Request, prefix string) bool {\n\tfor _, schema := range req.Tools {\n\t\tif strings.HasPrefix(schema.Name, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc toolSchemaNames(tools []provider.ToolSchema) []string {\n\tnames := make([]string, 0, len(tools))\n\tfor _, schema := range tools {\n\t\tnames = append(names, schema.Name)\n\t}\n\treturn names\n}\n\nfunc assertToolOrder(t *testing.T, tools []provider.ToolSchema, want []string) {\n\tt.Helper()\n\tnames := toolSchemaNames(tools)\n\tnext := 0\n\tfor _, name := range names {\n\t\tif next < len(want) && name == want[next] {\n\t\t\tnext++\n\t\t}\n\t}\n\tif next != len(want) {\n\t\tt.Fatalf(\"tool order changed; provider-visible tool schema order affects prompt-cache shape.\\nwant subsequence: %v\\n got: %v\", want, names)\n\t}\n}\n\nfunc firstTokenProfileRequest(t *testing.T, tokenMode string) provider.Request {\n\tt.Helper()\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-profile\", testutil.Turn{Text: \"done\"})\n\tsetBootTokenProfileTestProvider(t, prov)\n\n\topts := Options{Sink: event.Discard}\n\tif tokenMode != \"\" {\n\t\topts.TokenMode = tokenMode\n\t}\n\tctrl, err := Build(context.Background(), opts)\n\tif err != nil {\n\t\tt.Fatalf(\"Build(%q): %v\", tokenMode, err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"capture request prefix\"); err != nil {\n\t\tt.Fatalf(\"Run(%q): %v\", tokenMode, err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 1 {\n\t\tt.Fatalf(\"requests(%q) = %d, want 1\", tokenMode, len(reqs))\n\t}\n\treturn reqs[0]\n}\n\nfunc TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"first Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\tprov.setContinueRef(ref)\n\n\tif err := ctrl.Run(context.Background(), \"continue review\"); err != nil {\n\t\tt.Fatalf(\"second Run: %v\", err)\n\t}\n\tstore := agent.NewSubagentStore(filepath.Join(config.SessionDir(), \"subagents\"))\n\tmeta, err := store.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta: %v\", err)\n\t}\n\tif meta.Status != agent.SubagentFailed {\n\t\tt.Fatalf(\"status = %q, want failed\", meta.Status)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tsess, err := agent.LoadSession(filepath.Join(config.SessionDir(), \"subagents\", ref+\".jsonl\"))\n\tif err != nil {\n\t\tt.Fatalf(\"LoadSession: %v\", err)\n\t}\n\tmsgs := sess.Snapshot()\n\tif len(msgs) != 4 || msgs[1].Content != \"first skill task\" || msgs[2].Content != \"first skill answer\" || msgs[3].Content != \"second skill task\" {\n\t\tt.Fatalf(\"failed skill transcript = %+v, want first task/answer plus second task\", msgs)\n\t}\n}\n\nfunc TestBuildSubagentStoreHonorsSessionDirOverride(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\n\toverrideStore := agent.NewSubagentStore(filepath.Join(sessionDir, \"subagents\"))\n\tmeta, err := overrideStore.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta from override dir: %v\", err)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \"subagents\", ref+\".meta.json\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"subagent metadata should not be written to global session dir, stat err = %v\", err)\n\t}\n}\n\nfunc TestBuildSubagentSkillUsesLiveReasoningLanguage(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\nreasoning_language = \"zh\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tctrl.SetReasoningLanguage(\"auto\")\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.requestsSnapshot()\n\tif len(reqs) < 2 {\n\t\tt.Fatalf(\"provider requests = %d, want parent request plus skill subagent request\", len(reqs))\n\t}\n\tif got := bootLastUser(reqs[1]); strings.Contains(got, \"\") {\n\t\tt.Fatalf(\"skill subagent kept stale boot-time reasoning language after live auto update: %q\", got)\n\t}\n\tif got := bootLastUser(reqs[1]); got != \"first skill task\" {\n\t\tt.Fatalf(\"skill subagent user prompt = %q, want first skill task\", got)\n\t}\n}\n\nconst bootSubagentTestProviderKind = \"boot-subagent-test\"\n\nvar (\n\tbootSubagentTestProviderOnce sync.Once\n\tbootSubagentTestProviderCurrent *bootSubagentTestProvider\n\tbootSubagentTestProviderMu sync.Mutex\n)\n\nfunc registerBootSubagentTestProvider() {\n\tbootSubagentTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootSubagentTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootSubagentTestProviderMu.Lock()\n\t\t\tdefer bootSubagentTestProviderMu.Unlock()\n\t\t\tif bootSubagentTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot subagent test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootSubagentTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootSubagentTestProvider(t *testing.T, p *bootSubagentTestProvider) {\n\tt.Helper()\n\tbootSubagentTestProviderMu.Lock()\n\tbootSubagentTestProviderCurrent = p\n\tbootSubagentTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootSubagentTestProviderMu.Lock()\n\t\tif bootSubagentTestProviderCurrent == p {\n\t\t\tbootSubagentTestProviderCurrent = nil\n\t\t}\n\t\tbootSubagentTestProviderMu.Unlock()\n\t})\n}\n\ntype bootSubagentTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n\tcontinueRef string\n\trequests []provider.Request\n}\n\nfunc (p *bootSubagentTestProvider) Name() string { return \"boot-subagent-test\" }\n\nfunc (p *bootSubagentTestProvider) setContinueRef(ref string) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tp.continueRef = ref\n}\n\nfunc (p *bootSubagentTestProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tref := p.continueRef\n\tp.requests = append(p.requests, req)\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-1\", Name: \"review\", Arguments: `{\"task\":\"first skill task\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"first skill answer\"}, {Type: provider.ChunkDone}}\n\tcase 2:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent first done\"}, {Type: provider.ChunkDone}}\n\tcase 3:\n\t\targs, _ := json.Marshal(map[string]string{\"task\": \"second skill task\", \"continue_from\": ref})\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-2\", Name: \"review\", Arguments: string(args)}}}\n\tcase 4:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: errors.New(\"subagent skill failed\")}}\n\tcase 5:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent second done\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf(\"unexpected provider call %d\", call)}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc (p *bootSubagentTestProvider) requestsSnapshot() []provider.Request {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tout := make([]provider.Request, len(p.requests))\n\tcopy(out, p.requests)\n\treturn out\n}\n\nfunc bootLastUser(req provider.Request) string {\n\tfor i := len(req.Messages) - 1; i >= 0; i-- {\n\t\tif req.Messages[i].Role == provider.RoleUser {\n\t\t\treturn req.Messages[i].Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc subagentRefFromHistory(t *testing.T, msgs []provider.Message) string {\n\tt.Helper()\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, line := range strings.Split(msg.Content, \"\\n\") {\n\t\t\tif strings.HasPrefix(line, \"Subagent reference: \") {\n\t\t\t\treturn strings.TrimSpace(strings.TrimPrefix(line, \"Subagent reference: \"))\n\t\t\t}\n\t\t}\n\t}\n\tt.Fatalf(\"no subagent reference in history: %+v\", msgs)\n\treturn \"\"\n}\n\n// TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath reproduces headless\n// `reasonix run`: a controller built via Build with NO SetSessionPath (exactly\n// what internal/cli.runAgent does) must still be able to run a `task` sub-agent.\n// Before the ephemeral fallback this failed with \"parent session is required\".\nfunc TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterHeadlessTaskTestProvider()\n\tprov := &headlessTaskTestProvider{}\n\tsetHeadlessTaskTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-headless-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// Deliberately NOT calling SetSessionPath — this is the headless run path.\n\tif err := ctrl.Run(context.Background(), \"use a task subagent\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tif got := ctrl.SessionPath(); got != \"\" {\n\t\tt.Fatalf(\"headless run should keep an empty session path, got %q\", got)\n\t}\n\n\tvar toolContent string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolContent += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif strings.Contains(toolContent, \"parent session is required\") {\n\t\tt.Fatalf(\"task subagent failed in headless run mode: %s\", toolContent)\n\t}\n\tif !strings.Contains(toolContent, \"subagent answer\") {\n\t\tt.Fatalf(\"task tool result = %q, want sub-agent answer\", toolContent)\n\t}\n\tif strings.Contains(toolContent, \"Subagent reference\") {\n\t\tt.Fatalf(\"ephemeral headless run should not persist a transcript reference: %s\", toolContent)\n\t}\n}\n\nconst headlessTaskTestProviderKind = \"boot-headless-test\"\n\nvar (\n\theadlessTaskTestProviderOnce sync.Once\n\theadlessTaskTestProviderCurrent *headlessTaskTestProvider\n\theadlessTaskTestProviderMu sync.Mutex\n)\n\nfunc registerHeadlessTaskTestProvider() {\n\theadlessTaskTestProviderOnce.Do(func() {\n\t\tprovider.Register(headlessTaskTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\theadlessTaskTestProviderMu.Lock()\n\t\t\tdefer headlessTaskTestProviderMu.Unlock()\n\t\t\tif headlessTaskTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"headless task test provider is not installed\")\n\t\t\t}\n\t\t\treturn headlessTaskTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setHeadlessTaskTestProvider(t *testing.T, p *headlessTaskTestProvider) {\n\tt.Helper()\n\theadlessTaskTestProviderMu.Lock()\n\theadlessTaskTestProviderCurrent = p\n\theadlessTaskTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\theadlessTaskTestProviderMu.Lock()\n\t\tif headlessTaskTestProviderCurrent == p {\n\t\t\theadlessTaskTestProviderCurrent = nil\n\t\t}\n\t\theadlessTaskTestProviderMu.Unlock()\n\t})\n}\n\ntype headlessTaskTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n}\n\nfunc (p *headlessTaskTestProvider) Name() string { return \"boot-headless-test\" }\n\nfunc (p *headlessTaskTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"task-1\", Name: \"task\", Arguments: `{\"prompt\":\"find callers\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"subagent answer\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent done\"}, {Type: provider.ChunkDone}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\nfunc TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4-flash\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want high from DeepSeek model capability\", got)\n\t}\n\tthinking, ok := gotReq[\"thinking\"].(map[string]any)\n\tif !ok || thinking[\"type\"] != \"enabled\" {\n\t\tt.Fatalf(\"thinking = %#v, want enabled\", gotReq[\"thinking\"])\n\t}\n}\n\nfunc TestBuildHonorsSessionDirOverride(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.SessionDir(); got != sessionDir {\n\t\tt.Fatalf(\"SessionDir() = %q, want override %q\", got, sessionDir)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildTokenFullMatchesDefaultRequestPrefix(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tdefaultReq := firstTokenProfileRequest(t, \"\")\n\tfullReq := firstTokenProfileRequest(t, TokenModeFull)\n\n\tif got, want := systemMessage(defaultReq.Messages), systemMessage(fullReq.Messages); got != want {\n\t\tt.Fatalf(\"explicit full mode changed the system prompt\\n--- default ---\\n%s\\n--- full ---\\n%s\", got, want)\n\t}\n\tif strings.Contains(systemMessage(fullReq.Messages), tokenEconomyPrompt) {\n\t\tt.Fatalf(\"full mode system prompt should not include token economy prompt:\\n%s\", systemMessage(fullReq.Messages))\n\t}\n\tif !strings.Contains(systemMessage(fullReq.Messages), \"# Skills\") || !strings.Contains(systemMessage(fullReq.Messages), \"projskill\") {\n\t\tt.Fatalf(\"full mode should preserve the skills index in the system prompt:\\n%s\", systemMessage(fullReq.Messages))\n\t}\n\tif got, want := toolSchemaNames(fullReq.Tools), toolSchemaNames(defaultReq.Tools); !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"explicit full mode changed tool schema order\\nfull=%v\\ndefault=%v\", got, want)\n\t}\n\tif !reflect.DeepEqual(fullReq.Tools, defaultReq.Tools) {\n\t\tt.Fatalf(\"explicit full mode changed provider-visible tool schemas; names=%v\", toolSchemaNames(fullReq.Tools))\n\t}\n\tif requestHasTool(fullReq, \"connect_tool_source\") {\n\t\tt.Fatalf(\"full mode should not expose economy connector; tools=%v\", toolSchemaNames(fullReq.Tools))\n\t}\n}\n\nfunc TestBuildTokenEconomyStartsWithLeanToolSurface(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\", testutil.Turn{Text: \"done\"})\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n\n[[plugins]]\nname = \"mockmcp\"\ncommand = \"reasonix-missing-mockmcp\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"use the lean surface\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 1 {\n\t\tt.Fatalf(\"requests = %d, want 1\", len(reqs))\n\t}\n\treq := reqs[0]\n\twantTools := []string{\n\t\t\"ask\",\n\t\t\"bash\",\n\t\t\"bash_output\",\n\t\t\"complete_step\",\n\t\t\"connect_tool_source\",\n\t\t\"edit_file\",\n\t\t\"forget\",\n\t\t\"glob\",\n\t\t\"grep\",\n\t\t\"history\",\n\t\t\"kill_shell\",\n\t\t\"ls\",\n\t\t\"memory\",\n\t\t\"move_file\",\n\t\t\"multi_edit\",\n\t\t\"read_file\",\n\t\t\"remember\",\n\t\t\"slash_command\",\n\t\t\"todo_write\",\n\t\t\"wait\",\n\t\t\"write_file\",\n\t}\n\tif got := toolSchemaNames(req.Tools); !reflect.DeepEqual(got, wantTools) {\n\t\tt.Fatalf(\"economy first request tool order changed\\ngot %v\\nwant %v\", got, wantTools)\n\t}\n\tfor _, want := range []string{\"connect_tool_source\", \"read_file\", \"grep\", \"edit_file\", \"bash\", \"slash_command\", \"ask\"} {\n\t\tif !requestHasTool(req, want) {\n\t\t\tt.Fatalf(\"economy first request missing tool %q; tools=%v\", want, toolSchemaNames(req.Tools))\n\t\t}\n\t}\n\tfor _, forbidden := range []string{\n\t\t\"web_fetch\", \"task\", \"run_skill\", \"read_skill\", \"install_skill\", \"install_source\",\n\t\t\"explore\", \"research\", \"review\", \"security_review\",\n\t\t\"lsp_definition\", \"lsp_references\", \"lsp_hover\", \"lsp_diagnostics\",\n\t} {\n\t\tif requestHasTool(req, forbidden) {\n\t\t\tt.Fatalf(\"economy first request should hide %q; tools=%v\", forbidden, toolSchemaNames(req.Tools))\n\t\t}\n\t}\n\tif requestHasToolPrefix(req, \"mcp__mockmcp\") {\n\t\tt.Fatalf(\"economy first request should not expose MCP placeholders; tools=%v\", toolSchemaNames(req.Tools))\n\t}\n\tsys := systemMessage(req.Messages)\n\tif !strings.Contains(sys, tokenEconomyPrompt) {\n\t\tt.Fatalf(\"token economy prompt missing from system message:\\n%s\", sys)\n\t}\n\tif strings.Contains(sys, \"# Skills\") || strings.Contains(sys, \"projskill\") {\n\t\tt.Fatalf(\"skills index should not be in economy system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildTokenEconomyConnectsWebFetchOnDemand(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"fetch later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasTool(reqs[0], \"web_fetch\") {\n\t\tt.Fatalf(\"first request should hide web_fetch; tools=%v\", toolSchemaNames(reqs[0].Tools))\n\t}\n\tif !requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"second request should expose web_fetch after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n}\n\nfunc TestBuildTokenEconomyCodegraphSetsShortDaemonIdleTimeout(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tenvOut := filepath.Join(dir, \"codegraph-idle-env\")\n\tt.Setenv(\"REASONIX_CODEGRAPH_HELPER_ENV_OUT\", envOut)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"codegraph\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`, launcher))\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"enable codegraph later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasToolPrefix(reqs[0], \"mcp__codegraph__\") {\n\t\tt.Fatalf(\"first request should hide codegraph; tools=%v\", toolSchemaNames(reqs[0].Tools))\n\t}\n\tif !requestHasToolPrefix(reqs[1], \"mcp__codegraph__\") {\n\t\tt.Fatalf(\"second request should expose codegraph after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tgot, err := os.ReadFile(envOut)\n\tif err != nil {\n\t\tt.Fatalf(\"read codegraph idle timeout env: %v\", err)\n\t}\n\tif string(got) != codegraph.ReasonixDaemonIdleTimeoutMS {\n\t\tt.Fatalf(\"%s = %q; want %q\", codegraph.DaemonIdleTimeoutEnv, got, codegraph.ReasonixDaemonIdleTimeoutMS)\n\t}\n}\n\nfunc TestBuildTokenEconomyPlanModeCanConnectWebFetch(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tctrl.SetPlanMode(true)\n\tif err := ctrl.Run(context.Background(), \"fetch later while planning\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif !requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"second request should expose web_fetch in plan economy mode; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" && strings.Contains(msg.Content, \"blocked:\") {\n\t\t\tt.Fatalf(\"connect_tool_source should not be blocked in plan mode, got:\\n%s\", msg.Content)\n\t\t}\n\t}\n}\n\nfunc TestBuildTokenEconomyWebFetchConnectorHonorsDisabledBuiltin(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[tools]\nenabled = [\"read_file\", \"grep\"]\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"fetch later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"disabled web_fetch should not be exposed after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tvar toolOutput string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" {\n\t\t\ttoolOutput += msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolOutput, \"web_fetch is disabled by [tools].enabled\") {\n\t\tt.Fatalf(\"connector should explain disabled web_fetch, got:\\n%s\", toolOutput)\n\t}\n}\n\nfunc TestBuildTokenEconomyConnectsSkillsOnDemand(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"skills\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"use skills later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tfor _, name := range []string{\"run_skill\", \"read_skill\", \"explore\"} {\n\t\tif requestHasTool(reqs[0], name) {\n\t\t\tt.Fatalf(\"first request should hide %q; tools=%v\", name, toolSchemaNames(reqs[0].Tools))\n\t\t}\n\t\tif !requestHasTool(reqs[1], name) {\n\t\t\tt.Fatalf(\"second request should expose %q after connect_tool_source; tools=%v\", name, toolSchemaNames(reqs[1].Tools))\n\t\t}\n\t}\n\tvar toolOutput string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" {\n\t\t\ttoolOutput += msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolOutput, \"projskill\") || !strings.Contains(toolOutput, \"# Skills\") {\n\t\tt.Fatalf(\"skills source result should include the skill index, got:\\n%s\", toolOutput)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{})\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"move_file\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(workspace*)\"]\n`)\n\n\tconst rule = \"Bash(go test ./...)\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleCreatesWorkspaceConfigOverUserConfig(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tworkspace := robustTempDir(t)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user)\"]\n`)\n\n\tconst rule = \"Edit(src/app.go)\"\n\tres := rememberPermissionRule(workspace, rule)\n\tif !res.Saved || res.Path != filepath.Join(workspace, \"reasonix.toml\") {\n\t\tt.Fatalf(\"remember result = %+v, want saved to workspace config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule was written to user config: %v\", userCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule missing from project config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user*)\"]\n`)\n\n\tconst rule = \"Bash(go env)\"\n\tres := rememberPermissionRule(\"\", rule)\n\tif !res.Saved || res.Path != userConfig {\n\t\tt.Fatalf(\"remember result = %+v, want saved to user source config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc TestRememberPermissionRuleSkipsRuleCoveredByExistingAllow(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test:*)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test ./...)\")\n\tif res.Saved || res.CoveredBy != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"remember result = %+v, want already covered\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif len(cfg.Permissions.Allow) != 1 || cfg.Permissions.Allow[0] != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"allow rules = %v, want only existing prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRulePrunesNarrowRulesWhenSavingBroaderRule(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test ./...)\", \"Bash(go build ./...)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test:*)\")\n\tif !res.Saved || res.CoveredBy != \"\" {\n\t\tt.Fatalf(\"remember result = %+v, want saved broader rule\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test ./...)\") {\n\t\tt.Fatalf(\"narrow go test rule should be pruned: %v\", cfg.Permissions.Allow)\n\t}\n\tif !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go build ./...)\") || !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test:*)\") {\n\t\tt.Fatalf(\"allow rules = %v, want unrelated exact plus prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuiltInMCPsYieldToExtraPluginNames(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, []string{\"time\", \"context7\"}, pluginSpecNames([]plugin.Spec{{Name: \"time\"}})...)\n\tif len(got) != 1 || got[0].Name != \"context7\" {\n\t\tt.Fatalf(\"built-in MCP entries with extra time = %+v, want only context7\", got)\n\t}\n}\n\nfunc TestBuiltInMCPDefaultsEnableOnlyTime(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, config.Default().BuiltInMCP.EnabledNames())\n\tif len(got) != 1 || got[0].Name != \"time\" {\n\t\tt.Fatalf(\"default built-in MCP entries = %+v, want only time\", got)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildCodegraphSetsShortDaemonIdleTimeout(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tenvOut := filepath.Join(dir, \"codegraph-idle-env\")\n\tt.Setenv(\"REASONIX_CODEGRAPH_HELPER_ENV_OUT\", envOut)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tdeadline := time.Now().Add(5 * time.Second)\n\tvar got []byte\n\tfor {\n\t\tgot, err = os.ReadFile(envOut)\n\t\tif err == nil {\n\t\t\tbreak\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"codegraph helper never recorded idle timeout env: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tif string(got) != codegraph.ReasonixDaemonIdleTimeoutMS {\n\t\tt.Fatalf(\"%s = %q; want %q\", codegraph.DaemonIdleTimeoutEnv, got, codegraph.ReasonixDaemonIdleTimeoutMS)\n\t}\n}\n\nfunc TestBuildWarmCodegraphIgnoresLegacyEagerTier(t *testing.T) {\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"fake launcher is a POSIX-sh script\")\n\t}\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.Mkdir(filepath.Join(dir, \".codegraph\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tlauncher := filepath.Join(dir, \"slow-codegraph\")\n\twriteFile(t, dir, \"slow-codegraph\", \"#!/bin/sh\\nif [ \\\"$1\\\" = serve ]; then sleep 5; fi\\n\")\n\tif err := os.Chmod(launcher, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"eager\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not block on warm codegraph with legacy eager tier: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildDefaultsToNearestGitRoot(t *testing.T) {\n\tisolateConfigHome(t)\n\troot := robustTempDir(t)\n\tif err := os.Mkdir(filepath.Join(root, \".git\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsubdir := filepath.Join(root, \"cmd\", \"tool\")\n\tif err := os.MkdirAll(subdir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\twriteFile(t, root, \"reasonix.toml\", `\ndefault_model = \"root-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"root-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\tt.Chdir(subdir)\n\n\tctrl, err := Build(context.Background(), Options{Model: \"root-model\"})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should load config from nearest git root: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\tif len(os.Args) >= 2 && os.Args[1] == \"serve\" {\n\t\tif out := os.Getenv(\"REASONIX_CODEGRAPH_HELPER_ENV_OUT\"); out != \"\" {\n\t\t\t_ = os.WriteFile(out, []byte(os.Getenv(\"CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS\")), 0o644)\n\t\t}\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "a9760911fcde0c8a1631faf85925ad32724f1466": {"t": 1785750699.568344, "n": 68929, "h": "f93f5cadff5def6b", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/agent/testutil\"\n\t\"reasonix/internal/builtinmcp\"\n\t\"reasonix/internal/codegraph\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestBuildRegistersUsableHistoryAndMemoryRetrievalTools(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-retrieval-tool-test\"\nmodel = \"x\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"sessions\")\n\tif err := os.MkdirAll(sessionDir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tpast := agent.NewSession(\"\")\n\tpast.Add(provider.Message{Role: provider.RoleUser, Content: \"Should the history layer use vector embeddings?\"})\n\tpast.Add(provider.Message{Role: provider.RoleAssistant, Content: \"Decision: port lightweight BM25 history retrieval without a vector database.\"})\n\tif err := past.Save(filepath.Join(sessionDir, \"past.jsonl\")); err != nil {\n\t\tt.Fatalf(\"save past session: %v\", err)\n\t}\n\n\tstore := memory.StoreFor(config.MemoryUserDir(), dir)\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"synthesis-cache-policy\",\n\t\tDescription: \"Stable conclusions should be reused from memory\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t}); err != nil {\n\t\tt.Fatalf(\"save memory: %v\", err)\n\t}\n\n\tregisterBootRetrievalToolTestProvider()\n\tprov := testutil.NewMock(\"boot-retrieval-tool-test\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"history-1\", Name: \"history\", Arguments: `{\"operation\":\"search\",\"query\":\"BM25 vector database\",\"scope\":\"project\",\"limit\":5}`},\n\t\t\t{ID: \"memory-1\", Name: \"memory\", Arguments: `{\"operation\":\"search\",\"query\":\"synthesis cache stable conclusion\",\"limit\":5}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootRetrievalToolTestProvider(t, prov)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tfor _, forbidden := range []string{\n\t\t\"Decision: port lightweight BM25 history retrieval without a vector database.\",\n\t\t\"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t} {\n\t\tif strings.Contains(sys, forbidden) {\n\t\t\tt.Fatalf(\"retrieval content should stay behind on-demand tools, not enter the cache-stable system prompt:\\n%s\", sys)\n\t\t}\n\t}\n\n\tif err := ctrl.Run(context.Background(), \"recover past context\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) == 0 {\n\t\tt.Fatal(\"provider received no requests\")\n\t}\n\tfor _, want := range []string{\"history\", \"memory\", \"remember\", \"forget\"} {\n\t\tif !requestHasTool(reqs[0], want) {\n\t\t\tt.Fatalf(\"first request missing tool %q; tools=%v\", want, toolSchemaNames(reqs[0].Tools))\n\t\t}\n\t}\n\tassertToolOrder(t, reqs[0].Tools, []string{\"forget\", \"history\", \"memory\", \"remember\"})\n\n\ttoolResults := map[string]string{}\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolResults[msg.Name] += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolResults[\"history\"], \"port lightweight BM25 history retrieval\") {\n\t\tt.Fatalf(\"history tool result did not include saved session decision:\\n%s\", toolResults[\"history\"])\n\t}\n\tif !strings.Contains(toolResults[\"memory\"], \"synthesis-cache-policy\") ||\n\t\t!strings.Contains(toolResults[\"memory\"], \"stable conclusion\") {\n\t\tt.Fatalf(\"memory tool result did not include saved memory:\\n%s\", toolResults[\"memory\"])\n\t}\n}\n\nconst bootRetrievalToolTestProviderKind = \"boot-retrieval-tool-test\"\n\nvar (\n\tbootRetrievalToolTestProviderOnce sync.Once\n\tbootRetrievalToolTestProviderCurrent *testutil.MockProvider\n\tbootRetrievalToolTestProviderMu sync.Mutex\n)\n\nfunc registerBootRetrievalToolTestProvider() {\n\tbootRetrievalToolTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootRetrievalToolTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\t\tdefer bootRetrievalToolTestProviderMu.Unlock()\n\t\t\tif bootRetrievalToolTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot retrieval tool test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootRetrievalToolTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootRetrievalToolTestProvider(t *testing.T, p *testutil.MockProvider) {\n\tt.Helper()\n\tbootRetrievalToolTestProviderMu.Lock()\n\tbootRetrievalToolTestProviderCurrent = p\n\tbootRetrievalToolTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\tif bootRetrievalToolTestProviderCurrent == p {\n\t\t\tbootRetrievalToolTestProviderCurrent = nil\n\t\t}\n\t\tbootRetrievalToolTestProviderMu.Unlock()\n\t})\n}\n\nconst bootTokenProfileTestProviderKind = \"boot-token-profile-test\"\n\nvar (\n\tbootTokenProfileTestProviderOnce sync.Once\n\tbootTokenProfileTestProviderCurrent *testutil.MockProvider\n\tbootTokenProfileTestProviderMu sync.Mutex\n)\n\nfunc registerBootTokenProfileTestProvider() {\n\tbootTokenProfileTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootTokenProfileTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootTokenProfileTestProviderMu.Lock()\n\t\t\tdefer bootTokenProfileTestProviderMu.Unlock()\n\t\t\tif bootTokenProfileTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot token profile test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootTokenProfileTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootTokenProfileTestProvider(t *testing.T, p *testutil.MockProvider) {\n\tt.Helper()\n\tbootTokenProfileTestProviderMu.Lock()\n\tbootTokenProfileTestProviderCurrent = p\n\tbootTokenProfileTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootTokenProfileTestProviderMu.Lock()\n\t\tif bootTokenProfileTestProviderCurrent == p {\n\t\t\tbootTokenProfileTestProviderCurrent = nil\n\t\t}\n\t\tbootTokenProfileTestProviderMu.Unlock()\n\t})\n}\n\nfunc requestHasTool(req provider.Request, name string) bool {\n\tfor _, schema := range req.Tools {\n\t\tif schema.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc requestHasToolPrefix(req provider.Request, prefix string) bool {\n\tfor _, schema := range req.Tools {\n\t\tif strings.HasPrefix(schema.Name, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc toolSchemaNames(tools []provider.ToolSchema) []string {\n\tnames := make([]string, 0, len(tools))\n\tfor _, schema := range tools {\n\t\tnames = append(names, schema.Name)\n\t}\n\treturn names\n}\n\nfunc assertToolOrder(t *testing.T, tools []provider.ToolSchema, want []string) {\n\tt.Helper()\n\tnames := toolSchemaNames(tools)\n\tnext := 0\n\tfor _, name := range names {\n\t\tif next < len(want) && name == want[next] {\n\t\t\tnext++\n\t\t}\n\t}\n\tif next != len(want) {\n\t\tt.Fatalf(\"tool order changed; provider-visible tool schema order affects prompt-cache shape.\\nwant subsequence: %v\\n got: %v\", want, names)\n\t}\n}\n\nfunc firstTokenProfileRequest(t *testing.T, tokenMode string) provider.Request {\n\tt.Helper()\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-profile\", testutil.Turn{Text: \"done\"})\n\tsetBootTokenProfileTestProvider(t, prov)\n\n\topts := Options{Sink: event.Discard}\n\tif tokenMode != \"\" {\n\t\topts.TokenMode = tokenMode\n\t}\n\tctrl, err := Build(context.Background(), opts)\n\tif err != nil {\n\t\tt.Fatalf(\"Build(%q): %v\", tokenMode, err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"capture request prefix\"); err != nil {\n\t\tt.Fatalf(\"Run(%q): %v\", tokenMode, err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 1 {\n\t\tt.Fatalf(\"requests(%q) = %d, want 1\", tokenMode, len(reqs))\n\t}\n\treturn reqs[0]\n}\n\nfunc TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"first Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\tprov.setContinueRef(ref)\n\n\tif err := ctrl.Run(context.Background(), \"continue review\"); err != nil {\n\t\tt.Fatalf(\"second Run: %v\", err)\n\t}\n\tstore := agent.NewSubagentStore(filepath.Join(config.SessionDir(), \"subagents\"))\n\tmeta, err := store.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta: %v\", err)\n\t}\n\tif meta.Status != agent.SubagentFailed {\n\t\tt.Fatalf(\"status = %q, want failed\", meta.Status)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tsess, err := agent.LoadSession(filepath.Join(config.SessionDir(), \"subagents\", ref+\".jsonl\"))\n\tif err != nil {\n\t\tt.Fatalf(\"LoadSession: %v\", err)\n\t}\n\tmsgs := sess.Snapshot()\n\tif len(msgs) != 4 || msgs[1].Content != \"first skill task\" || msgs[2].Content != \"first skill answer\" || msgs[3].Content != \"second skill task\" {\n\t\tt.Fatalf(\"failed skill transcript = %+v, want first task/answer plus second task\", msgs)\n\t}\n}\n\nfunc TestBuildSubagentStoreHonorsSessionDirOverride(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\n\toverrideStore := agent.NewSubagentStore(filepath.Join(sessionDir, \"subagents\"))\n\tmeta, err := overrideStore.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta from override dir: %v\", err)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \"subagents\", ref+\".meta.json\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"subagent metadata should not be written to global session dir, stat err = %v\", err)\n\t}\n}\n\nfunc TestBuildSubagentSkillUsesLiveReasoningLanguage(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\nreasoning_language = \"zh\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tctrl.SetReasoningLanguage(\"auto\")\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.requestsSnapshot()\n\tif len(reqs) < 2 {\n\t\tt.Fatalf(\"provider requests = %d, want parent request plus skill subagent request\", len(reqs))\n\t}\n\tif got := bootLastUser(reqs[1]); strings.Contains(got, \"\") {\n\t\tt.Fatalf(\"skill subagent kept stale boot-time reasoning language after live auto update: %q\", got)\n\t}\n\tif got := bootLastUser(reqs[1]); got != \"first skill task\" {\n\t\tt.Fatalf(\"skill subagent user prompt = %q, want first skill task\", got)\n\t}\n}\n\nconst bootSubagentTestProviderKind = \"boot-subagent-test\"\n\nvar (\n\tbootSubagentTestProviderOnce sync.Once\n\tbootSubagentTestProviderCurrent *bootSubagentTestProvider\n\tbootSubagentTestProviderMu sync.Mutex\n)\n\nfunc registerBootSubagentTestProvider() {\n\tbootSubagentTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootSubagentTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootSubagentTestProviderMu.Lock()\n\t\t\tdefer bootSubagentTestProviderMu.Unlock()\n\t\t\tif bootSubagentTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot subagent test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootSubagentTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootSubagentTestProvider(t *testing.T, p *bootSubagentTestProvider) {\n\tt.Helper()\n\tbootSubagentTestProviderMu.Lock()\n\tbootSubagentTestProviderCurrent = p\n\tbootSubagentTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootSubagentTestProviderMu.Lock()\n\t\tif bootSubagentTestProviderCurrent == p {\n\t\t\tbootSubagentTestProviderCurrent = nil\n\t\t}\n\t\tbootSubagentTestProviderMu.Unlock()\n\t})\n}\n\ntype bootSubagentTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n\tcontinueRef string\n\trequests []provider.Request\n}\n\nfunc (p *bootSubagentTestProvider) Name() string { return \"boot-subagent-test\" }\n\nfunc (p *bootSubagentTestProvider) setContinueRef(ref string) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tp.continueRef = ref\n}\n\nfunc (p *bootSubagentTestProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tref := p.continueRef\n\tp.requests = append(p.requests, req)\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-1\", Name: \"review\", Arguments: `{\"task\":\"first skill task\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"first skill answer\"}, {Type: provider.ChunkDone}}\n\tcase 2:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent first done\"}, {Type: provider.ChunkDone}}\n\tcase 3:\n\t\targs, _ := json.Marshal(map[string]string{\"task\": \"second skill task\", \"continue_from\": ref})\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-2\", Name: \"review\", Arguments: string(args)}}}\n\tcase 4:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: errors.New(\"subagent skill failed\")}}\n\tcase 5:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent second done\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf(\"unexpected provider call %d\", call)}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc (p *bootSubagentTestProvider) requestsSnapshot() []provider.Request {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tout := make([]provider.Request, len(p.requests))\n\tcopy(out, p.requests)\n\treturn out\n}\n\nfunc bootLastUser(req provider.Request) string {\n\tfor i := len(req.Messages) - 1; i >= 0; i-- {\n\t\tif req.Messages[i].Role == provider.RoleUser {\n\t\t\treturn req.Messages[i].Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc subagentRefFromHistory(t *testing.T, msgs []provider.Message) string {\n\tt.Helper()\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, line := range strings.Split(msg.Content, \"\\n\") {\n\t\t\tif strings.HasPrefix(line, \"Subagent reference: \") {\n\t\t\t\treturn strings.TrimSpace(strings.TrimPrefix(line, \"Subagent reference: \"))\n\t\t\t}\n\t\t}\n\t}\n\tt.Fatalf(\"no subagent reference in history: %+v\", msgs)\n\treturn \"\"\n}\n\n// TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath reproduces headless\n// `reasonix run`: a controller built via Build with NO SetSessionPath (exactly\n// what internal/cli.runAgent does) must still be able to run a `task` sub-agent.\n// Before the ephemeral fallback this failed with \"parent session is required\".\nfunc TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterHeadlessTaskTestProvider()\n\tprov := &headlessTaskTestProvider{}\n\tsetHeadlessTaskTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-headless-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// Deliberately NOT calling SetSessionPath — this is the headless run path.\n\tif err := ctrl.Run(context.Background(), \"use a task subagent\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tif got := ctrl.SessionPath(); got != \"\" {\n\t\tt.Fatalf(\"headless run should keep an empty session path, got %q\", got)\n\t}\n\n\tvar toolContent string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolContent += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif strings.Contains(toolContent, \"parent session is required\") {\n\t\tt.Fatalf(\"task subagent failed in headless run mode: %s\", toolContent)\n\t}\n\tif !strings.Contains(toolContent, \"subagent answer\") {\n\t\tt.Fatalf(\"task tool result = %q, want sub-agent answer\", toolContent)\n\t}\n\tif strings.Contains(toolContent, \"Subagent reference\") {\n\t\tt.Fatalf(\"ephemeral headless run should not persist a transcript reference: %s\", toolContent)\n\t}\n}\n\nconst headlessTaskTestProviderKind = \"boot-headless-test\"\n\nvar (\n\theadlessTaskTestProviderOnce sync.Once\n\theadlessTaskTestProviderCurrent *headlessTaskTestProvider\n\theadlessTaskTestProviderMu sync.Mutex\n)\n\nfunc registerHeadlessTaskTestProvider() {\n\theadlessTaskTestProviderOnce.Do(func() {\n\t\tprovider.Register(headlessTaskTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\theadlessTaskTestProviderMu.Lock()\n\t\t\tdefer headlessTaskTestProviderMu.Unlock()\n\t\t\tif headlessTaskTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"headless task test provider is not installed\")\n\t\t\t}\n\t\t\treturn headlessTaskTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setHeadlessTaskTestProvider(t *testing.T, p *headlessTaskTestProvider) {\n\tt.Helper()\n\theadlessTaskTestProviderMu.Lock()\n\theadlessTaskTestProviderCurrent = p\n\theadlessTaskTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\theadlessTaskTestProviderMu.Lock()\n\t\tif headlessTaskTestProviderCurrent == p {\n\t\t\theadlessTaskTestProviderCurrent = nil\n\t\t}\n\t\theadlessTaskTestProviderMu.Unlock()\n\t})\n}\n\ntype headlessTaskTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n}\n\nfunc (p *headlessTaskTestProvider) Name() string { return \"boot-headless-test\" }\n\nfunc (p *headlessTaskTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"task-1\", Name: \"task\", Arguments: `{\"prompt\":\"find callers\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"subagent answer\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent done\"}, {Type: provider.ChunkDone}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\nfunc TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4-flash\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want high from DeepSeek model capability\", got)\n\t}\n\tthinking, ok := gotReq[\"thinking\"].(map[string]any)\n\tif !ok || thinking[\"type\"] != \"enabled\" {\n\t\tt.Fatalf(\"thinking = %#v, want enabled\", gotReq[\"thinking\"])\n\t}\n}\n\nfunc TestBuildHonorsSessionDirOverride(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.SessionDir(); got != sessionDir {\n\t\tt.Fatalf(\"SessionDir() = %q, want override %q\", got, sessionDir)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildTokenFullMatchesDefaultRequestPrefix(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tdefaultReq := firstTokenProfileRequest(t, \"\")\n\tfullReq := firstTokenProfileRequest(t, TokenModeFull)\n\n\tif got, want := systemMessage(defaultReq.Messages), systemMessage(fullReq.Messages); got != want {\n\t\tt.Fatalf(\"explicit full mode changed the system prompt\\n--- default ---\\n%s\\n--- full ---\\n%s\", got, want)\n\t}\n\tif strings.Contains(systemMessage(fullReq.Messages), tokenEconomyPrompt) {\n\t\tt.Fatalf(\"full mode system prompt should not include token economy prompt:\\n%s\", systemMessage(fullReq.Messages))\n\t}\n\tif !strings.Contains(systemMessage(fullReq.Messages), \"# Skills\") || !strings.Contains(systemMessage(fullReq.Messages), \"projskill\") {\n\t\tt.Fatalf(\"full mode should preserve the skills index in the system prompt:\\n%s\", systemMessage(fullReq.Messages))\n\t}\n\tif got, want := toolSchemaNames(fullReq.Tools), toolSchemaNames(defaultReq.Tools); !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"explicit full mode changed tool schema order\\nfull=%v\\ndefault=%v\", got, want)\n\t}\n\tif !reflect.DeepEqual(fullReq.Tools, defaultReq.Tools) {\n\t\tt.Fatalf(\"explicit full mode changed provider-visible tool schemas; names=%v\", toolSchemaNames(fullReq.Tools))\n\t}\n\tif requestHasTool(fullReq, \"connect_tool_source\") {\n\t\tt.Fatalf(\"full mode should not expose economy connector; tools=%v\", toolSchemaNames(fullReq.Tools))\n\t}\n}\n\nfunc TestBuildTokenEconomyStartsWithLeanToolSurface(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\", testutil.Turn{Text: \"done\"})\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n\n[[plugins]]\nname = \"mockmcp\"\ncommand = \"reasonix-missing-mockmcp\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"use the lean surface\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 1 {\n\t\tt.Fatalf(\"requests = %d, want 1\", len(reqs))\n\t}\n\treq := reqs[0]\n\twantTools := []string{\n\t\t\"ask\",\n\t\t\"bash\",\n\t\t\"bash_output\",\n\t\t\"complete_step\",\n\t\t\"connect_tool_source\",\n\t\t\"edit_file\",\n\t\t\"forget\",\n\t\t\"glob\",\n\t\t\"grep\",\n\t\t\"history\",\n\t\t\"kill_shell\",\n\t\t\"ls\",\n\t\t\"memory\",\n\t\t\"move_file\",\n\t\t\"multi_edit\",\n\t\t\"read_file\",\n\t\t\"remember\",\n\t\t\"slash_command\",\n\t\t\"todo_write\",\n\t\t\"wait\",\n\t\t\"write_file\",\n\t}\n\tif got := toolSchemaNames(req.Tools); !reflect.DeepEqual(got, wantTools) {\n\t\tt.Fatalf(\"economy first request tool order changed\\ngot %v\\nwant %v\", got, wantTools)\n\t}\n\tfor _, want := range []string{\"connect_tool_source\", \"read_file\", \"grep\", \"edit_file\", \"bash\", \"slash_command\", \"ask\"} {\n\t\tif !requestHasTool(req, want) {\n\t\t\tt.Fatalf(\"economy first request missing tool %q; tools=%v\", want, toolSchemaNames(req.Tools))\n\t\t}\n\t}\n\tfor _, forbidden := range []string{\n\t\t\"web_fetch\", \"task\", \"run_skill\", \"read_skill\", \"install_skill\", \"install_source\",\n\t\t\"explore\", \"research\", \"review\", \"security_review\",\n\t\t\"lsp_definition\", \"lsp_references\", \"lsp_hover\", \"lsp_diagnostics\",\n\t} {\n\t\tif requestHasTool(req, forbidden) {\n\t\t\tt.Fatalf(\"economy first request should hide %q; tools=%v\", forbidden, toolSchemaNames(req.Tools))\n\t\t}\n\t}\n\tif requestHasToolPrefix(req, \"mcp__mockmcp\") {\n\t\tt.Fatalf(\"economy first request should not expose MCP placeholders; tools=%v\", toolSchemaNames(req.Tools))\n\t}\n\tsys := systemMessage(req.Messages)\n\tif !strings.Contains(sys, tokenEconomyPrompt) {\n\t\tt.Fatalf(\"token economy prompt missing from system message:\\n%s\", sys)\n\t}\n\tif strings.Contains(sys, \"# Skills\") || strings.Contains(sys, \"projskill\") {\n\t\tt.Fatalf(\"skills index should not be in economy system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildTokenEconomyConnectsWebFetchOnDemand(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"fetch later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasTool(reqs[0], \"web_fetch\") {\n\t\tt.Fatalf(\"first request should hide web_fetch; tools=%v\", toolSchemaNames(reqs[0].Tools))\n\t}\n\tif !requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"second request should expose web_fetch after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n}\n\nfunc TestBuildTokenEconomyPlanModeCanConnectWebFetch(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tctrl.SetPlanMode(true)\n\tif err := ctrl.Run(context.Background(), \"fetch later while planning\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif !requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"second request should expose web_fetch in plan economy mode; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" && strings.Contains(msg.Content, \"blocked:\") {\n\t\t\tt.Fatalf(\"connect_tool_source should not be blocked in plan mode, got:\\n%s\", msg.Content)\n\t\t}\n\t}\n}\n\nfunc TestBuildTokenEconomyWebFetchConnectorHonorsDisabledBuiltin(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[tools]\nenabled = [\"read_file\", \"grep\"]\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"fetch later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"disabled web_fetch should not be exposed after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tvar toolOutput string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" {\n\t\t\ttoolOutput += msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolOutput, \"web_fetch is disabled by [tools].enabled\") {\n\t\tt.Fatalf(\"connector should explain disabled web_fetch, got:\\n%s\", toolOutput)\n\t}\n}\n\nfunc TestBuildTokenEconomyConnectsSkillsOnDemand(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"skills\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"use skills later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tfor _, name := range []string{\"run_skill\", \"read_skill\", \"explore\"} {\n\t\tif requestHasTool(reqs[0], name) {\n\t\t\tt.Fatalf(\"first request should hide %q; tools=%v\", name, toolSchemaNames(reqs[0].Tools))\n\t\t}\n\t\tif !requestHasTool(reqs[1], name) {\n\t\t\tt.Fatalf(\"second request should expose %q after connect_tool_source; tools=%v\", name, toolSchemaNames(reqs[1].Tools))\n\t\t}\n\t}\n\tvar toolOutput string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" {\n\t\t\ttoolOutput += msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolOutput, \"projskill\") || !strings.Contains(toolOutput, \"# Skills\") {\n\t\tt.Fatalf(\"skills source result should include the skill index, got:\\n%s\", toolOutput)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{})\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"move_file\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(workspace*)\"]\n`)\n\n\tconst rule = \"Bash(go test ./...)\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleCreatesWorkspaceConfigOverUserConfig(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tworkspace := robustTempDir(t)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user)\"]\n`)\n\n\tconst rule = \"Edit(src/app.go)\"\n\tres := rememberPermissionRule(workspace, rule)\n\tif !res.Saved || res.Path != filepath.Join(workspace, \"reasonix.toml\") {\n\t\tt.Fatalf(\"remember result = %+v, want saved to workspace config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule was written to user config: %v\", userCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule missing from project config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user*)\"]\n`)\n\n\tconst rule = \"Bash(go env)\"\n\tres := rememberPermissionRule(\"\", rule)\n\tif !res.Saved || res.Path != userConfig {\n\t\tt.Fatalf(\"remember result = %+v, want saved to user source config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc TestRememberPermissionRuleSkipsRuleCoveredByExistingAllow(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test:*)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test ./...)\")\n\tif res.Saved || res.CoveredBy != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"remember result = %+v, want already covered\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif len(cfg.Permissions.Allow) != 1 || cfg.Permissions.Allow[0] != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"allow rules = %v, want only existing prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRulePrunesNarrowRulesWhenSavingBroaderRule(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test ./...)\", \"Bash(go build ./...)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test:*)\")\n\tif !res.Saved || res.CoveredBy != \"\" {\n\t\tt.Fatalf(\"remember result = %+v, want saved broader rule\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test ./...)\") {\n\t\tt.Fatalf(\"narrow go test rule should be pruned: %v\", cfg.Permissions.Allow)\n\t}\n\tif !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go build ./...)\") || !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test:*)\") {\n\t\tt.Fatalf(\"allow rules = %v, want unrelated exact plus prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuiltInMCPsYieldToExtraPluginNames(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, []string{\"time\", \"context7\"}, pluginSpecNames([]plugin.Spec{{Name: \"time\"}})...)\n\tif len(got) != 1 || got[0].Name != \"context7\" {\n\t\tt.Fatalf(\"built-in MCP entries with extra time = %+v, want only context7\", got)\n\t}\n}\n\nfunc TestBuiltInMCPDefaultsEnableOnlyTime(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, config.Default().BuiltInMCP.EnabledNames())\n\tif len(got) != 1 || got[0].Name != \"time\" {\n\t\tt.Fatalf(\"default built-in MCP entries = %+v, want only time\", got)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildCodegraphSetsShortDaemonIdleTimeout(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tenvOut := filepath.Join(dir, \"codegraph-idle-env\")\n\tt.Setenv(\"REASONIX_CODEGRAPH_HELPER_ENV_OUT\", envOut)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tdeadline := time.Now().Add(5 * time.Second)\n\tvar got []byte\n\tfor {\n\t\tgot, err = os.ReadFile(envOut)\n\t\tif err == nil {\n\t\t\tbreak\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"codegraph helper never recorded idle timeout env: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tif string(got) != codegraph.ReasonixDaemonIdleTimeoutMS {\n\t\tt.Fatalf(\"%s = %q; want %q\", codegraph.DaemonIdleTimeoutEnv, got, codegraph.ReasonixDaemonIdleTimeoutMS)\n\t}\n}\n\nfunc TestBuildWarmCodegraphIgnoresLegacyEagerTier(t *testing.T) {\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"fake launcher is a POSIX-sh script\")\n\t}\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.Mkdir(filepath.Join(dir, \".codegraph\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tlauncher := filepath.Join(dir, \"slow-codegraph\")\n\twriteFile(t, dir, \"slow-codegraph\", \"#!/bin/sh\\nif [ \\\"$1\\\" = serve ]; then sleep 5; fi\\n\")\n\tif err := os.Chmod(launcher, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"eager\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not block on warm codegraph with legacy eager tier: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildDefaultsToNearestGitRoot(t *testing.T) {\n\tisolateConfigHome(t)\n\troot := robustTempDir(t)\n\tif err := os.Mkdir(filepath.Join(root, \".git\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsubdir := filepath.Join(root, \"cmd\", \"tool\")\n\tif err := os.MkdirAll(subdir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\twriteFile(t, root, \"reasonix.toml\", `\ndefault_model = \"root-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"root-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\tt.Chdir(subdir)\n\n\tctrl, err := Build(context.Background(), Options{Model: \"root-model\"})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should load config from nearest git root: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\tif len(os.Args) >= 2 && os.Args[1] == \"serve\" {\n\t\tif out := os.Getenv(\"REASONIX_CODEGRAPH_HELPER_ENV_OUT\"); out != \"\" {\n\t\t\t_ = os.WriteFile(out, []byte(os.Getenv(\"CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS\")), 0o644)\n\t\t}\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "61bb5c8768d91965b3576402441b2c58a6e88d2f": {"t": 1785750700.9406195, "n": 68899, "h": "725eb296a734f34b", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/agent/testutil\"\n\t\"reasonix/internal/builtinmcp\"\n\t\"reasonix/internal/codegraph\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestBuildRegistersUsableHistoryAndMemoryRetrievalTools(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-retrieval-tool-test\"\nmodel = \"x\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"sessions\")\n\tif err := os.MkdirAll(sessionDir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tpast := agent.NewSession(\"\")\n\tpast.Add(provider.Message{Role: provider.RoleUser, Content: \"Should the history layer use vector embeddings?\"})\n\tpast.Add(provider.Message{Role: provider.RoleAssistant, Content: \"Decision: port lightweight BM25 history retrieval without a vector database.\"})\n\tif err := past.Save(filepath.Join(sessionDir, \"past.jsonl\")); err != nil {\n\t\tt.Fatalf(\"save past session: %v\", err)\n\t}\n\n\tstore := memory.StoreFor(config.MemoryUserDir(), dir)\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"synthesis-cache-policy\",\n\t\tDescription: \"Stable conclusions should be reused from memory\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t}); err != nil {\n\t\tt.Fatalf(\"save memory: %v\", err)\n\t}\n\n\tregisterBootRetrievalToolTestProvider()\n\tprov := testutil.NewMock(\"boot-retrieval-tool-test\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"history-1\", Name: \"history\", Arguments: `{\"operation\":\"search\",\"query\":\"BM25 vector database\",\"scope\":\"project\",\"limit\":5}`},\n\t\t\t{ID: \"memory-1\", Name: \"memory\", Arguments: `{\"operation\":\"search\",\"query\":\"synthesis cache stable conclusion\",\"limit\":5}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootRetrievalToolTestProvider(t, prov)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tfor _, forbidden := range []string{\n\t\t\"Decision: port lightweight BM25 history retrieval without a vector database.\",\n\t\t\"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t} {\n\t\tif strings.Contains(sys, forbidden) {\n\t\t\tt.Fatalf(\"retrieval content should stay behind on-demand tools, not enter the cache-stable system prompt:\\n%s\", sys)\n\t\t}\n\t}\n\n\tif err := ctrl.Run(context.Background(), \"recover past context\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) == 0 {\n\t\tt.Fatal(\"provider received no requests\")\n\t}\n\tfor _, want := range []string{\"history\", \"memory\", \"remember\", \"forget\"} {\n\t\tif !requestHasTool(reqs[0], want) {\n\t\t\tt.Fatalf(\"first request missing tool %q; tools=%v\", want, toolSchemaNames(reqs[0].Tools))\n\t\t}\n\t}\n\tassertToolOrder(t, reqs[0].Tools, []string{\"forget\", \"history\", \"memory\", \"remember\"})\n\n\ttoolResults := map[string]string{}\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolResults[msg.Name] += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolResults[\"history\"], \"port lightweight BM25 history retrieval\") {\n\t\tt.Fatalf(\"history tool result did not include saved session decision:\\n%s\", toolResults[\"history\"])\n\t}\n\tif !strings.Contains(toolResults[\"memory\"], \"synthesis-cache-policy\") ||\n\t\t!strings.Contains(toolResults[\"memory\"], \"stable conclusion\") {\n\t\tt.Fatalf(\"memory tool result did not include saved memory:\\n%s\", toolResults[\"memory\"])\n\t}\n}\n\nconst bootRetrievalToolTestProviderKind = \"boot-retrieval-tool-test\"\n\nvar (\n\tbootRetrievalToolTestProviderOnce sync.Once\n\tbootRetrievalToolTestProviderCurrent *testutil.MockProvider\n\tbootRetrievalToolTestProviderMu sync.Mutex\n)\n\nfunc registerBootRetrievalToolTestProvider() {\n\tbootRetrievalToolTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootRetrievalToolTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\t\tdefer bootRetrievalToolTestProviderMu.Unlock()\n\t\t\tif bootRetrievalToolTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot retrieval tool test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootRetrievalToolTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootRetrievalToolTestProvider(t *testing.T, p *testutil.MockProvider) {\n\tt.Helper()\n\tbootRetrievalToolTestProviderMu.Lock()\n\tbootRetrievalToolTestProviderCurrent = p\n\tbootRetrievalToolTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\tif bootRetrievalToolTestProviderCurrent == p {\n\t\t\tbootRetrievalToolTestProviderCurrent = nil\n\t\t}\n\t\tbootRetrievalToolTestProviderMu.Unlock()\n\t})\n}\n\nconst bootTokenProfileTestProviderKind = \"boot-token-profile-test\"\n\nvar (\n\tbootTokenProfileTestProviderOnce sync.Once\n\tbootTokenProfileTestProviderCurrent *testutil.MockProvider\n\tbootTokenProfileTestProviderMu sync.Mutex\n)\n\nfunc registerBootTokenProfileTestProvider() {\n\tbootTokenProfileTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootTokenProfileTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootTokenProfileTestProviderMu.Lock()\n\t\t\tdefer bootTokenProfileTestProviderMu.Unlock()\n\t\t\tif bootTokenProfileTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot token profile test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootTokenProfileTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootTokenProfileTestProvider(t *testing.T, p *testutil.MockProvider) {\n\tt.Helper()\n\tbootTokenProfileTestProviderMu.Lock()\n\tbootTokenProfileTestProviderCurrent = p\n\tbootTokenProfileTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootTokenProfileTestProviderMu.Lock()\n\t\tif bootTokenProfileTestProviderCurrent == p {\n\t\t\tbootTokenProfileTestProviderCurrent = nil\n\t\t}\n\t\tbootTokenProfileTestProviderMu.Unlock()\n\t})\n}\n\nfunc requestHasTool(req provider.Request, name string) bool {\n\tfor _, schema := range req.Tools {\n\t\tif schema.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc requestHasToolPrefix(req provider.Request, prefix string) bool {\n\tfor _, schema := range req.Tools {\n\t\tif strings.HasPrefix(schema.Name, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc toolSchemaNames(tools []provider.ToolSchema) []string {\n\tnames := make([]string, 0, len(tools))\n\tfor _, schema := range tools {\n\t\tnames = append(names, schema.Name)\n\t}\n\treturn names\n}\n\nfunc assertToolOrder(t *testing.T, tools []provider.ToolSchema, want []string) {\n\tt.Helper()\n\tnames := toolSchemaNames(tools)\n\tnext := 0\n\tfor _, name := range names {\n\t\tif next < len(want) && name == want[next] {\n\t\t\tnext++\n\t\t}\n\t}\n\tif next != len(want) {\n\t\tt.Fatalf(\"tool order changed; provider-visible tool schema order affects prompt-cache shape.\\nwant subsequence: %v\\n got: %v\", want, names)\n\t}\n}\n\nfunc firstTokenProfileRequest(t *testing.T, tokenMode string) provider.Request {\n\tt.Helper()\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-profile\", testutil.Turn{Text: \"done\"})\n\tsetBootTokenProfileTestProvider(t, prov)\n\n\topts := Options{Sink: event.Discard}\n\tif tokenMode != \"\" {\n\t\topts.TokenMode = tokenMode\n\t}\n\tctrl, err := Build(context.Background(), opts)\n\tif err != nil {\n\t\tt.Fatalf(\"Build(%q): %v\", tokenMode, err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"capture request prefix\"); err != nil {\n\t\tt.Fatalf(\"Run(%q): %v\", tokenMode, err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 1 {\n\t\tt.Fatalf(\"requests(%q) = %d, want 1\", tokenMode, len(reqs))\n\t}\n\treturn reqs[0]\n}\n\nfunc TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"first Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\tprov.setContinueRef(ref)\n\n\tif err := ctrl.Run(context.Background(), \"continue review\"); err != nil {\n\t\tt.Fatalf(\"second Run: %v\", err)\n\t}\n\tstore := agent.NewSubagentStore(filepath.Join(config.SessionDir(), \"subagents\"))\n\tmeta, err := store.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta: %v\", err)\n\t}\n\tif meta.Status != agent.SubagentFailed {\n\t\tt.Fatalf(\"status = %q, want failed\", meta.Status)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tsess, err := agent.LoadSession(filepath.Join(config.SessionDir(), \"subagents\", ref+\".jsonl\"))\n\tif err != nil {\n\t\tt.Fatalf(\"LoadSession: %v\", err)\n\t}\n\tmsgs := sess.Snapshot()\n\tif len(msgs) != 4 || msgs[1].Content != \"first skill task\" || msgs[2].Content != \"first skill answer\" || msgs[3].Content != \"second skill task\" {\n\t\tt.Fatalf(\"failed skill transcript = %+v, want first task/answer plus second task\", msgs)\n\t}\n}\n\nfunc TestBuildSubagentStoreHonorsSessionDirOverride(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\n\toverrideStore := agent.NewSubagentStore(filepath.Join(sessionDir, \"subagents\"))\n\tmeta, err := overrideStore.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta from override dir: %v\", err)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \"subagents\", ref+\".meta.json\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"subagent metadata should not be written to global session dir, stat err = %v\", err)\n\t}\n}\n\nfunc TestBuildSubagentSkillUsesLiveReasoningLanguage(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\nreasoning_language = \"zh\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tctrl.SetReasoningLanguage(\"auto\")\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.requestsSnapshot()\n\tif len(reqs) < 2 {\n\t\tt.Fatalf(\"provider requests = %d, want parent request plus skill subagent request\", len(reqs))\n\t}\n\tif got := bootLastUser(reqs[1]); strings.Contains(got, \"\") {\n\t\tt.Fatalf(\"skill subagent kept stale boot-time reasoning language after live auto update: %q\", got)\n\t}\n\tif got := bootLastUser(reqs[1]); got != \"first skill task\" {\n\t\tt.Fatalf(\"skill subagent user prompt = %q, want first skill task\", got)\n\t}\n}\n\nconst bootSubagentTestProviderKind = \"boot-subagent-test\"\n\nvar (\n\tbootSubagentTestProviderOnce sync.Once\n\tbootSubagentTestProviderCurrent *bootSubagentTestProvider\n\tbootSubagentTestProviderMu sync.Mutex\n)\n\nfunc registerBootSubagentTestProvider() {\n\tbootSubagentTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootSubagentTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootSubagentTestProviderMu.Lock()\n\t\t\tdefer bootSubagentTestProviderMu.Unlock()\n\t\t\tif bootSubagentTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot subagent test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootSubagentTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootSubagentTestProvider(t *testing.T, p *bootSubagentTestProvider) {\n\tt.Helper()\n\tbootSubagentTestProviderMu.Lock()\n\tbootSubagentTestProviderCurrent = p\n\tbootSubagentTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootSubagentTestProviderMu.Lock()\n\t\tif bootSubagentTestProviderCurrent == p {\n\t\t\tbootSubagentTestProviderCurrent = nil\n\t\t}\n\t\tbootSubagentTestProviderMu.Unlock()\n\t})\n}\n\ntype bootSubagentTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n\tcontinueRef string\n\trequests []provider.Request\n}\n\nfunc (p *bootSubagentTestProvider) Name() string { return \"boot-subagent-test\" }\n\nfunc (p *bootSubagentTestProvider) setContinueRef(ref string) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tp.continueRef = ref\n}\n\nfunc (p *bootSubagentTestProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tref := p.continueRef\n\tp.requests = append(p.requests, req)\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-1\", Name: \"review\", Arguments: `{\"task\":\"first skill task\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"first skill answer\"}, {Type: provider.ChunkDone}}\n\tcase 2:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent first done\"}, {Type: provider.ChunkDone}}\n\tcase 3:\n\t\targs, _ := json.Marshal(map[string]string{\"task\": \"second skill task\", \"continue_from\": ref})\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-2\", Name: \"review\", Arguments: string(args)}}}\n\tcase 4:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: errors.New(\"subagent skill failed\")}}\n\tcase 5:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent second done\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf(\"unexpected provider call %d\", call)}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc (p *bootSubagentTestProvider) requestsSnapshot() []provider.Request {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tout := make([]provider.Request, len(p.requests))\n\tcopy(out, p.requests)\n\treturn out\n}\n\nfunc bootLastUser(req provider.Request) string {\n\tfor i := len(req.Messages) - 1; i >= 0; i-- {\n\t\tif req.Messages[i].Role == provider.RoleUser {\n\t\t\treturn req.Messages[i].Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc subagentRefFromHistory(t *testing.T, msgs []provider.Message) string {\n\tt.Helper()\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, line := range strings.Split(msg.Content, \"\\n\") {\n\t\t\tif strings.HasPrefix(line, \"Subagent reference: \") {\n\t\t\t\treturn strings.TrimSpace(strings.TrimPrefix(line, \"Subagent reference: \"))\n\t\t\t}\n\t\t}\n\t}\n\tt.Fatalf(\"no subagent reference in history: %+v\", msgs)\n\treturn \"\"\n}\n\n// TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath reproduces headless\n// `reasonix run`: a controller built via Build with NO SetSessionPath (exactly\n// what internal/cli.runAgent does) must still be able to run a `task` sub-agent.\n// Before the ephemeral fallback this failed with \"parent session is required\".\nfunc TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterHeadlessTaskTestProvider()\n\tprov := &headlessTaskTestProvider{}\n\tsetHeadlessTaskTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-headless-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// Deliberately NOT calling SetSessionPath — this is the headless run path.\n\tif err := ctrl.Run(context.Background(), \"use a task subagent\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tif got := ctrl.SessionPath(); got != \"\" {\n\t\tt.Fatalf(\"headless run should keep an empty session path, got %q\", got)\n\t}\n\n\tvar toolContent string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolContent += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif strings.Contains(toolContent, \"parent session is required\") {\n\t\tt.Fatalf(\"task subagent failed in headless run mode: %s\", toolContent)\n\t}\n\tif !strings.Contains(toolContent, \"subagent answer\") {\n\t\tt.Fatalf(\"task tool result = %q, want sub-agent answer\", toolContent)\n\t}\n\tif strings.Contains(toolContent, \"Subagent reference\") {\n\t\tt.Fatalf(\"ephemeral headless run should not persist a transcript reference: %s\", toolContent)\n\t}\n}\n\nconst headlessTaskTestProviderKind = \"boot-headless-test\"\n\nvar (\n\theadlessTaskTestProviderOnce sync.Once\n\theadlessTaskTestProviderCurrent *headlessTaskTestProvider\n\theadlessTaskTestProviderMu sync.Mutex\n)\n\nfunc registerHeadlessTaskTestProvider() {\n\theadlessTaskTestProviderOnce.Do(func() {\n\t\tprovider.Register(headlessTaskTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\theadlessTaskTestProviderMu.Lock()\n\t\t\tdefer headlessTaskTestProviderMu.Unlock()\n\t\t\tif headlessTaskTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"headless task test provider is not installed\")\n\t\t\t}\n\t\t\treturn headlessTaskTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setHeadlessTaskTestProvider(t *testing.T, p *headlessTaskTestProvider) {\n\tt.Helper()\n\theadlessTaskTestProviderMu.Lock()\n\theadlessTaskTestProviderCurrent = p\n\theadlessTaskTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\theadlessTaskTestProviderMu.Lock()\n\t\tif headlessTaskTestProviderCurrent == p {\n\t\t\theadlessTaskTestProviderCurrent = nil\n\t\t}\n\t\theadlessTaskTestProviderMu.Unlock()\n\t})\n}\n\ntype headlessTaskTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n}\n\nfunc (p *headlessTaskTestProvider) Name() string { return \"boot-headless-test\" }\n\nfunc (p *headlessTaskTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"task-1\", Name: \"task\", Arguments: `{\"prompt\":\"find callers\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"subagent answer\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent done\"}, {Type: provider.ChunkDone}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\nfunc TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4-flash\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want high from DeepSeek model capability\", got)\n\t}\n\tthinking, ok := gotReq[\"thinking\"].(map[string]any)\n\tif !ok || thinking[\"type\"] != \"enabled\" {\n\t\tt.Fatalf(\"thinking = %#v, want enabled\", gotReq[\"thinking\"])\n\t}\n}\n\nfunc TestBuildHonorsSessionDirOverride(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.SessionDir(); got != sessionDir {\n\t\tt.Fatalf(\"SessionDir() = %q, want override %q\", got, sessionDir)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildTokenFullMatchesDefaultRequestPrefix(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tdefaultReq := firstTokenProfileRequest(t, \"\")\n\tfullReq := firstTokenProfileRequest(t, TokenModeFull)\n\n\tif got, want := systemMessage(defaultReq.Messages), systemMessage(fullReq.Messages); got != want {\n\t\tt.Fatalf(\"explicit full mode changed the system prompt\\n--- default ---\\n%s\\n--- full ---\\n%s\", got, want)\n\t}\n\tif strings.Contains(systemMessage(fullReq.Messages), tokenEconomyPrompt) {\n\t\tt.Fatalf(\"full mode system prompt should not include token economy prompt:\\n%s\", systemMessage(fullReq.Messages))\n\t}\n\tif !strings.Contains(systemMessage(fullReq.Messages), \"# Skills\") || !strings.Contains(systemMessage(fullReq.Messages), \"projskill\") {\n\t\tt.Fatalf(\"full mode should preserve the skills index in the system prompt:\\n%s\", systemMessage(fullReq.Messages))\n\t}\n\tif got, want := toolSchemaNames(fullReq.Tools), toolSchemaNames(defaultReq.Tools); !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"explicit full mode changed tool schema order\\nfull=%v\\ndefault=%v\", got, want)\n\t}\n\tif !reflect.DeepEqual(fullReq.Tools, defaultReq.Tools) {\n\t\tt.Fatalf(\"explicit full mode changed provider-visible tool schemas; names=%v\", toolSchemaNames(fullReq.Tools))\n\t}\n\tif requestHasTool(fullReq, \"connect_tool_source\") {\n\t\tt.Fatalf(\"full mode should not expose economy connector; tools=%v\", toolSchemaNames(fullReq.Tools))\n\t}\n}\n\nfunc TestBuildTokenEconomyStartsWithLeanToolSurface(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\", testutil.Turn{Text: \"done\"})\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n\n[[plugins]]\nname = \"mockmcp\"\ncommand = \"reasonix-missing-mockmcp\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"use the lean surface\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 1 {\n\t\tt.Fatalf(\"requests = %d, want 1\", len(reqs))\n\t}\n\treq := reqs[0]\n\twantTools := []string{\n\t\t\"ask\",\n\t\t\"bash\",\n\t\t\"bash_output\",\n\t\t\"complete_step\",\n\t\t\"connect_tool_source\",\n\t\t\"edit_file\",\n\t\t\"forget\",\n\t\t\"glob\",\n\t\t\"grep\",\n\t\t\"history\",\n\t\t\"kill_shell\",\n\t\t\"ls\",\n\t\t\"memory\",\n\t\t\"multi_edit\",\n\t\t\"read_file\",\n\t\t\"remember\",\n\t\t\"slash_command\",\n\t\t\"todo_write\",\n\t\t\"wait\",\n\t\t\"write_file\",\n\t}\n\tif got := toolSchemaNames(req.Tools); !reflect.DeepEqual(got, wantTools) {\n\t\tt.Fatalf(\"economy first request tool order changed\\ngot %v\\nwant %v\", got, wantTools)\n\t}\n\tfor _, want := range []string{\"connect_tool_source\", \"read_file\", \"grep\", \"edit_file\", \"bash\", \"slash_command\", \"ask\"} {\n\t\tif !requestHasTool(req, want) {\n\t\t\tt.Fatalf(\"economy first request missing tool %q; tools=%v\", want, toolSchemaNames(req.Tools))\n\t\t}\n\t}\n\tfor _, forbidden := range []string{\n\t\t\"web_fetch\", \"task\", \"run_skill\", \"read_skill\", \"install_skill\", \"install_source\",\n\t\t\"explore\", \"research\", \"review\", \"security_review\",\n\t\t\"lsp_definition\", \"lsp_references\", \"lsp_hover\", \"lsp_diagnostics\",\n\t} {\n\t\tif requestHasTool(req, forbidden) {\n\t\t\tt.Fatalf(\"economy first request should hide %q; tools=%v\", forbidden, toolSchemaNames(req.Tools))\n\t\t}\n\t}\n\tif requestHasToolPrefix(req, \"mcp__mockmcp\") {\n\t\tt.Fatalf(\"economy first request should not expose MCP placeholders; tools=%v\", toolSchemaNames(req.Tools))\n\t}\n\tsys := systemMessage(req.Messages)\n\tif !strings.Contains(sys, tokenEconomyPrompt) {\n\t\tt.Fatalf(\"token economy prompt missing from system message:\\n%s\", sys)\n\t}\n\tif strings.Contains(sys, \"# Skills\") || strings.Contains(sys, \"projskill\") {\n\t\tt.Fatalf(\"skills index should not be in economy system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildTokenEconomyConnectsWebFetchOnDemand(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"fetch later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasTool(reqs[0], \"web_fetch\") {\n\t\tt.Fatalf(\"first request should hide web_fetch; tools=%v\", toolSchemaNames(reqs[0].Tools))\n\t}\n\tif !requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"second request should expose web_fetch after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n}\n\nfunc TestBuildTokenEconomyPlanModeCanConnectWebFetch(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tctrl.SetPlanMode(true)\n\tif err := ctrl.Run(context.Background(), \"fetch later while planning\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif !requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"second request should expose web_fetch in plan economy mode; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" && strings.Contains(msg.Content, \"blocked:\") {\n\t\t\tt.Fatalf(\"connect_tool_source should not be blocked in plan mode, got:\\n%s\", msg.Content)\n\t\t}\n\t}\n}\n\nfunc TestBuildTokenEconomyWebFetchConnectorHonorsDisabledBuiltin(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[tools]\nenabled = [\"read_file\", \"grep\"]\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"fetch later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"disabled web_fetch should not be exposed after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tvar toolOutput string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" {\n\t\t\ttoolOutput += msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolOutput, \"web_fetch is disabled by [tools].enabled\") {\n\t\tt.Fatalf(\"connector should explain disabled web_fetch, got:\\n%s\", toolOutput)\n\t}\n}\n\nfunc TestBuildTokenEconomyConnectsSkillsOnDemand(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"skills\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"use skills later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tfor _, name := range []string{\"run_skill\", \"read_skill\", \"explore\"} {\n\t\tif requestHasTool(reqs[0], name) {\n\t\t\tt.Fatalf(\"first request should hide %q; tools=%v\", name, toolSchemaNames(reqs[0].Tools))\n\t\t}\n\t\tif !requestHasTool(reqs[1], name) {\n\t\t\tt.Fatalf(\"second request should expose %q after connect_tool_source; tools=%v\", name, toolSchemaNames(reqs[1].Tools))\n\t\t}\n\t}\n\tvar toolOutput string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" {\n\t\t\ttoolOutput += msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolOutput, \"projskill\") || !strings.Contains(toolOutput, \"# Skills\") {\n\t\tt.Fatalf(\"skills source result should include the skill index, got:\\n%s\", toolOutput)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{})\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(workspace*)\"]\n`)\n\n\tconst rule = \"Bash(go test ./...)\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleCreatesWorkspaceConfigOverUserConfig(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tworkspace := robustTempDir(t)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user)\"]\n`)\n\n\tconst rule = \"Edit(src/app.go)\"\n\tres := rememberPermissionRule(workspace, rule)\n\tif !res.Saved || res.Path != filepath.Join(workspace, \"reasonix.toml\") {\n\t\tt.Fatalf(\"remember result = %+v, want saved to workspace config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule was written to user config: %v\", userCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule missing from project config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user*)\"]\n`)\n\n\tconst rule = \"Bash(go env)\"\n\tres := rememberPermissionRule(\"\", rule)\n\tif !res.Saved || res.Path != userConfig {\n\t\tt.Fatalf(\"remember result = %+v, want saved to user source config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc TestRememberPermissionRuleSkipsRuleCoveredByExistingAllow(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test:*)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test ./...)\")\n\tif res.Saved || res.CoveredBy != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"remember result = %+v, want already covered\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif len(cfg.Permissions.Allow) != 1 || cfg.Permissions.Allow[0] != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"allow rules = %v, want only existing prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRulePrunesNarrowRulesWhenSavingBroaderRule(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test ./...)\", \"Bash(go build ./...)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test:*)\")\n\tif !res.Saved || res.CoveredBy != \"\" {\n\t\tt.Fatalf(\"remember result = %+v, want saved broader rule\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test ./...)\") {\n\t\tt.Fatalf(\"narrow go test rule should be pruned: %v\", cfg.Permissions.Allow)\n\t}\n\tif !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go build ./...)\") || !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test:*)\") {\n\t\tt.Fatalf(\"allow rules = %v, want unrelated exact plus prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuiltInMCPsYieldToExtraPluginNames(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, []string{\"time\", \"context7\"}, pluginSpecNames([]plugin.Spec{{Name: \"time\"}})...)\n\tif len(got) != 1 || got[0].Name != \"context7\" {\n\t\tt.Fatalf(\"built-in MCP entries with extra time = %+v, want only context7\", got)\n\t}\n}\n\nfunc TestBuiltInMCPDefaultsEnableOnlyTime(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, config.Default().BuiltInMCP.EnabledNames())\n\tif len(got) != 1 || got[0].Name != \"time\" {\n\t\tt.Fatalf(\"default built-in MCP entries = %+v, want only time\", got)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildCodegraphSetsShortDaemonIdleTimeout(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tenvOut := filepath.Join(dir, \"codegraph-idle-env\")\n\tt.Setenv(\"REASONIX_CODEGRAPH_HELPER_ENV_OUT\", envOut)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tdeadline := time.Now().Add(5 * time.Second)\n\tvar got []byte\n\tfor {\n\t\tgot, err = os.ReadFile(envOut)\n\t\tif err == nil {\n\t\t\tbreak\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"codegraph helper never recorded idle timeout env: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tif string(got) != codegraph.ReasonixDaemonIdleTimeoutMS {\n\t\tt.Fatalf(\"%s = %q; want %q\", codegraph.DaemonIdleTimeoutEnv, got, codegraph.ReasonixDaemonIdleTimeoutMS)\n\t}\n}\n\nfunc TestBuildWarmCodegraphIgnoresLegacyEagerTier(t *testing.T) {\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"fake launcher is a POSIX-sh script\")\n\t}\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.Mkdir(filepath.Join(dir, \".codegraph\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tlauncher := filepath.Join(dir, \"slow-codegraph\")\n\twriteFile(t, dir, \"slow-codegraph\", \"#!/bin/sh\\nif [ \\\"$1\\\" = serve ]; then sleep 5; fi\\n\")\n\tif err := os.Chmod(launcher, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"eager\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not block on warm codegraph with legacy eager tier: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildDefaultsToNearestGitRoot(t *testing.T) {\n\tisolateConfigHome(t)\n\troot := robustTempDir(t)\n\tif err := os.Mkdir(filepath.Join(root, \".git\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsubdir := filepath.Join(root, \"cmd\", \"tool\")\n\tif err := os.MkdirAll(subdir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\twriteFile(t, root, \"reasonix.toml\", `\ndefault_model = \"root-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"root-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\tt.Chdir(subdir)\n\n\tctrl, err := Build(context.Background(), Options{Model: \"root-model\"})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should load config from nearest git root: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\tif len(os.Args) >= 2 && os.Args[1] == \"serve\" {\n\t\tif out := os.Getenv(\"REASONIX_CODEGRAPH_HELPER_ENV_OUT\"); out != \"\" {\n\t\t\t_ = os.WriteFile(out, []byte(os.Getenv(\"CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS\")), 0o644)\n\t\t}\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "edd4996fc35256f4b918925ead875567724f7aec": {"t": 1785750702.3228502, "n": 67191, "h": "55498332397bae2a", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/agent/testutil\"\n\t\"reasonix/internal/builtinmcp\"\n\t\"reasonix/internal/codegraph\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestBuildRegistersUsableHistoryAndMemoryRetrievalTools(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-retrieval-tool-test\"\nmodel = \"x\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"sessions\")\n\tif err := os.MkdirAll(sessionDir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tpast := agent.NewSession(\"\")\n\tpast.Add(provider.Message{Role: provider.RoleUser, Content: \"Should the history layer use vector embeddings?\"})\n\tpast.Add(provider.Message{Role: provider.RoleAssistant, Content: \"Decision: port lightweight BM25 history retrieval without a vector database.\"})\n\tif err := past.Save(filepath.Join(sessionDir, \"past.jsonl\")); err != nil {\n\t\tt.Fatalf(\"save past session: %v\", err)\n\t}\n\n\tstore := memory.StoreFor(config.MemoryUserDir(), dir)\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"synthesis-cache-policy\",\n\t\tDescription: \"Stable conclusions should be reused from memory\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t}); err != nil {\n\t\tt.Fatalf(\"save memory: %v\", err)\n\t}\n\n\tregisterBootRetrievalToolTestProvider()\n\tprov := testutil.NewMock(\"boot-retrieval-tool-test\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"history-1\", Name: \"history\", Arguments: `{\"operation\":\"search\",\"query\":\"BM25 vector database\",\"scope\":\"project\",\"limit\":5}`},\n\t\t\t{ID: \"memory-1\", Name: \"memory\", Arguments: `{\"operation\":\"search\",\"query\":\"synthesis cache stable conclusion\",\"limit\":5}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootRetrievalToolTestProvider(t, prov)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tfor _, forbidden := range []string{\n\t\t\"Decision: port lightweight BM25 history retrieval without a vector database.\",\n\t\t\"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t} {\n\t\tif strings.Contains(sys, forbidden) {\n\t\t\tt.Fatalf(\"retrieval content should stay behind on-demand tools, not enter the cache-stable system prompt:\\n%s\", sys)\n\t\t}\n\t}\n\n\tif err := ctrl.Run(context.Background(), \"recover past context\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) == 0 {\n\t\tt.Fatal(\"provider received no requests\")\n\t}\n\tfor _, want := range []string{\"history\", \"memory\", \"remember\", \"forget\"} {\n\t\tif !requestHasTool(reqs[0], want) {\n\t\t\tt.Fatalf(\"first request missing tool %q; tools=%v\", want, toolSchemaNames(reqs[0].Tools))\n\t\t}\n\t}\n\tassertToolOrder(t, reqs[0].Tools, []string{\"forget\", \"history\", \"memory\", \"remember\"})\n\n\ttoolResults := map[string]string{}\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolResults[msg.Name] += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolResults[\"history\"], \"port lightweight BM25 history retrieval\") {\n\t\tt.Fatalf(\"history tool result did not include saved session decision:\\n%s\", toolResults[\"history\"])\n\t}\n\tif !strings.Contains(toolResults[\"memory\"], \"synthesis-cache-policy\") ||\n\t\t!strings.Contains(toolResults[\"memory\"], \"stable conclusion\") {\n\t\tt.Fatalf(\"memory tool result did not include saved memory:\\n%s\", toolResults[\"memory\"])\n\t}\n}\n\nconst bootRetrievalToolTestProviderKind = \"boot-retrieval-tool-test\"\n\nvar (\n\tbootRetrievalToolTestProviderOnce sync.Once\n\tbootRetrievalToolTestProviderCurrent *testutil.MockProvider\n\tbootRetrievalToolTestProviderMu sync.Mutex\n)\n\nfunc registerBootRetrievalToolTestProvider() {\n\tbootRetrievalToolTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootRetrievalToolTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\t\tdefer bootRetrievalToolTestProviderMu.Unlock()\n\t\t\tif bootRetrievalToolTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot retrieval tool test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootRetrievalToolTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootRetrievalToolTestProvider(t *testing.T, p *testutil.MockProvider) {\n\tt.Helper()\n\tbootRetrievalToolTestProviderMu.Lock()\n\tbootRetrievalToolTestProviderCurrent = p\n\tbootRetrievalToolTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\tif bootRetrievalToolTestProviderCurrent == p {\n\t\t\tbootRetrievalToolTestProviderCurrent = nil\n\t\t}\n\t\tbootRetrievalToolTestProviderMu.Unlock()\n\t})\n}\n\nconst bootTokenProfileTestProviderKind = \"boot-token-profile-test\"\n\nvar (\n\tbootTokenProfileTestProviderOnce sync.Once\n\tbootTokenProfileTestProviderCurrent *testutil.MockProvider\n\tbootTokenProfileTestProviderMu sync.Mutex\n)\n\nfunc registerBootTokenProfileTestProvider() {\n\tbootTokenProfileTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootTokenProfileTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootTokenProfileTestProviderMu.Lock()\n\t\t\tdefer bootTokenProfileTestProviderMu.Unlock()\n\t\t\tif bootTokenProfileTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot token profile test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootTokenProfileTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootTokenProfileTestProvider(t *testing.T, p *testutil.MockProvider) {\n\tt.Helper()\n\tbootTokenProfileTestProviderMu.Lock()\n\tbootTokenProfileTestProviderCurrent = p\n\tbootTokenProfileTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootTokenProfileTestProviderMu.Lock()\n\t\tif bootTokenProfileTestProviderCurrent == p {\n\t\t\tbootTokenProfileTestProviderCurrent = nil\n\t\t}\n\t\tbootTokenProfileTestProviderMu.Unlock()\n\t})\n}\n\nfunc requestHasTool(req provider.Request, name string) bool {\n\tfor _, schema := range req.Tools {\n\t\tif schema.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc requestHasToolPrefix(req provider.Request, prefix string) bool {\n\tfor _, schema := range req.Tools {\n\t\tif strings.HasPrefix(schema.Name, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc toolSchemaNames(tools []provider.ToolSchema) []string {\n\tnames := make([]string, 0, len(tools))\n\tfor _, schema := range tools {\n\t\tnames = append(names, schema.Name)\n\t}\n\treturn names\n}\n\nfunc assertToolOrder(t *testing.T, tools []provider.ToolSchema, want []string) {\n\tt.Helper()\n\tnames := toolSchemaNames(tools)\n\tnext := 0\n\tfor _, name := range names {\n\t\tif next < len(want) && name == want[next] {\n\t\t\tnext++\n\t\t}\n\t}\n\tif next != len(want) {\n\t\tt.Fatalf(\"tool order changed; provider-visible tool schema order affects prompt-cache shape.\\nwant subsequence: %v\\n got: %v\", want, names)\n\t}\n}\n\nfunc firstTokenProfileRequest(t *testing.T, tokenMode string) provider.Request {\n\tt.Helper()\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-profile\", testutil.Turn{Text: \"done\"})\n\tsetBootTokenProfileTestProvider(t, prov)\n\n\topts := Options{Sink: event.Discard}\n\tif tokenMode != \"\" {\n\t\topts.TokenMode = tokenMode\n\t}\n\tctrl, err := Build(context.Background(), opts)\n\tif err != nil {\n\t\tt.Fatalf(\"Build(%q): %v\", tokenMode, err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"capture request prefix\"); err != nil {\n\t\tt.Fatalf(\"Run(%q): %v\", tokenMode, err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 1 {\n\t\tt.Fatalf(\"requests(%q) = %d, want 1\", tokenMode, len(reqs))\n\t}\n\treturn reqs[0]\n}\n\nfunc TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"first Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\tprov.setContinueRef(ref)\n\n\tif err := ctrl.Run(context.Background(), \"continue review\"); err != nil {\n\t\tt.Fatalf(\"second Run: %v\", err)\n\t}\n\tstore := agent.NewSubagentStore(filepath.Join(config.SessionDir(), \"subagents\"))\n\tmeta, err := store.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta: %v\", err)\n\t}\n\tif meta.Status != agent.SubagentFailed {\n\t\tt.Fatalf(\"status = %q, want failed\", meta.Status)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tsess, err := agent.LoadSession(filepath.Join(config.SessionDir(), \"subagents\", ref+\".jsonl\"))\n\tif err != nil {\n\t\tt.Fatalf(\"LoadSession: %v\", err)\n\t}\n\tmsgs := sess.Snapshot()\n\tif len(msgs) != 4 || msgs[1].Content != \"first skill task\" || msgs[2].Content != \"first skill answer\" || msgs[3].Content != \"second skill task\" {\n\t\tt.Fatalf(\"failed skill transcript = %+v, want first task/answer plus second task\", msgs)\n\t}\n}\n\nfunc TestBuildSubagentStoreHonorsSessionDirOverride(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\n\toverrideStore := agent.NewSubagentStore(filepath.Join(sessionDir, \"subagents\"))\n\tmeta, err := overrideStore.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta from override dir: %v\", err)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \"subagents\", ref+\".meta.json\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"subagent metadata should not be written to global session dir, stat err = %v\", err)\n\t}\n}\n\nconst bootSubagentTestProviderKind = \"boot-subagent-test\"\n\nvar (\n\tbootSubagentTestProviderOnce sync.Once\n\tbootSubagentTestProviderCurrent *bootSubagentTestProvider\n\tbootSubagentTestProviderMu sync.Mutex\n)\n\nfunc registerBootSubagentTestProvider() {\n\tbootSubagentTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootSubagentTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootSubagentTestProviderMu.Lock()\n\t\t\tdefer bootSubagentTestProviderMu.Unlock()\n\t\t\tif bootSubagentTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot subagent test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootSubagentTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootSubagentTestProvider(t *testing.T, p *bootSubagentTestProvider) {\n\tt.Helper()\n\tbootSubagentTestProviderMu.Lock()\n\tbootSubagentTestProviderCurrent = p\n\tbootSubagentTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootSubagentTestProviderMu.Lock()\n\t\tif bootSubagentTestProviderCurrent == p {\n\t\t\tbootSubagentTestProviderCurrent = nil\n\t\t}\n\t\tbootSubagentTestProviderMu.Unlock()\n\t})\n}\n\ntype bootSubagentTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n\tcontinueRef string\n}\n\nfunc (p *bootSubagentTestProvider) Name() string { return \"boot-subagent-test\" }\n\nfunc (p *bootSubagentTestProvider) setContinueRef(ref string) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tp.continueRef = ref\n}\n\nfunc (p *bootSubagentTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tref := p.continueRef\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-1\", Name: \"review\", Arguments: `{\"task\":\"first skill task\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"first skill answer\"}, {Type: provider.ChunkDone}}\n\tcase 2:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent first done\"}, {Type: provider.ChunkDone}}\n\tcase 3:\n\t\targs, _ := json.Marshal(map[string]string{\"task\": \"second skill task\", \"continue_from\": ref})\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-2\", Name: \"review\", Arguments: string(args)}}}\n\tcase 4:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: errors.New(\"subagent skill failed\")}}\n\tcase 5:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent second done\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf(\"unexpected provider call %d\", call)}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc subagentRefFromHistory(t *testing.T, msgs []provider.Message) string {\n\tt.Helper()\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, line := range strings.Split(msg.Content, \"\\n\") {\n\t\t\tif strings.HasPrefix(line, \"Subagent reference: \") {\n\t\t\t\treturn strings.TrimSpace(strings.TrimPrefix(line, \"Subagent reference: \"))\n\t\t\t}\n\t\t}\n\t}\n\tt.Fatalf(\"no subagent reference in history: %+v\", msgs)\n\treturn \"\"\n}\n\n// TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath reproduces headless\n// `reasonix run`: a controller built via Build with NO SetSessionPath (exactly\n// what internal/cli.runAgent does) must still be able to run a `task` sub-agent.\n// Before the ephemeral fallback this failed with \"parent session is required\".\nfunc TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterHeadlessTaskTestProvider()\n\tprov := &headlessTaskTestProvider{}\n\tsetHeadlessTaskTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-headless-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// Deliberately NOT calling SetSessionPath — this is the headless run path.\n\tif err := ctrl.Run(context.Background(), \"use a task subagent\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tif got := ctrl.SessionPath(); got != \"\" {\n\t\tt.Fatalf(\"headless run should keep an empty session path, got %q\", got)\n\t}\n\n\tvar toolContent string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolContent += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif strings.Contains(toolContent, \"parent session is required\") {\n\t\tt.Fatalf(\"task subagent failed in headless run mode: %s\", toolContent)\n\t}\n\tif !strings.Contains(toolContent, \"subagent answer\") {\n\t\tt.Fatalf(\"task tool result = %q, want sub-agent answer\", toolContent)\n\t}\n\tif strings.Contains(toolContent, \"Subagent reference\") {\n\t\tt.Fatalf(\"ephemeral headless run should not persist a transcript reference: %s\", toolContent)\n\t}\n}\n\nconst headlessTaskTestProviderKind = \"boot-headless-test\"\n\nvar (\n\theadlessTaskTestProviderOnce sync.Once\n\theadlessTaskTestProviderCurrent *headlessTaskTestProvider\n\theadlessTaskTestProviderMu sync.Mutex\n)\n\nfunc registerHeadlessTaskTestProvider() {\n\theadlessTaskTestProviderOnce.Do(func() {\n\t\tprovider.Register(headlessTaskTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\theadlessTaskTestProviderMu.Lock()\n\t\t\tdefer headlessTaskTestProviderMu.Unlock()\n\t\t\tif headlessTaskTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"headless task test provider is not installed\")\n\t\t\t}\n\t\t\treturn headlessTaskTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setHeadlessTaskTestProvider(t *testing.T, p *headlessTaskTestProvider) {\n\tt.Helper()\n\theadlessTaskTestProviderMu.Lock()\n\theadlessTaskTestProviderCurrent = p\n\theadlessTaskTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\theadlessTaskTestProviderMu.Lock()\n\t\tif headlessTaskTestProviderCurrent == p {\n\t\t\theadlessTaskTestProviderCurrent = nil\n\t\t}\n\t\theadlessTaskTestProviderMu.Unlock()\n\t})\n}\n\ntype headlessTaskTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n}\n\nfunc (p *headlessTaskTestProvider) Name() string { return \"boot-headless-test\" }\n\nfunc (p *headlessTaskTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"task-1\", Name: \"task\", Arguments: `{\"prompt\":\"find callers\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"subagent answer\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent done\"}, {Type: provider.ChunkDone}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\nfunc TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4-flash\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want high from DeepSeek model capability\", got)\n\t}\n\tthinking, ok := gotReq[\"thinking\"].(map[string]any)\n\tif !ok || thinking[\"type\"] != \"enabled\" {\n\t\tt.Fatalf(\"thinking = %#v, want enabled\", gotReq[\"thinking\"])\n\t}\n}\n\nfunc TestBuildHonorsSessionDirOverride(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.SessionDir(); got != sessionDir {\n\t\tt.Fatalf(\"SessionDir() = %q, want override %q\", got, sessionDir)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildTokenFullMatchesDefaultRequestPrefix(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tdefaultReq := firstTokenProfileRequest(t, \"\")\n\tfullReq := firstTokenProfileRequest(t, TokenModeFull)\n\n\tif got, want := systemMessage(defaultReq.Messages), systemMessage(fullReq.Messages); got != want {\n\t\tt.Fatalf(\"explicit full mode changed the system prompt\\n--- default ---\\n%s\\n--- full ---\\n%s\", got, want)\n\t}\n\tif strings.Contains(systemMessage(fullReq.Messages), tokenEconomyPrompt) {\n\t\tt.Fatalf(\"full mode system prompt should not include token economy prompt:\\n%s\", systemMessage(fullReq.Messages))\n\t}\n\tif !strings.Contains(systemMessage(fullReq.Messages), \"# Skills\") || !strings.Contains(systemMessage(fullReq.Messages), \"projskill\") {\n\t\tt.Fatalf(\"full mode should preserve the skills index in the system prompt:\\n%s\", systemMessage(fullReq.Messages))\n\t}\n\tif got, want := toolSchemaNames(fullReq.Tools), toolSchemaNames(defaultReq.Tools); !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"explicit full mode changed tool schema order\\nfull=%v\\ndefault=%v\", got, want)\n\t}\n\tif !reflect.DeepEqual(fullReq.Tools, defaultReq.Tools) {\n\t\tt.Fatalf(\"explicit full mode changed provider-visible tool schemas; names=%v\", toolSchemaNames(fullReq.Tools))\n\t}\n\tif requestHasTool(fullReq, \"connect_tool_source\") {\n\t\tt.Fatalf(\"full mode should not expose economy connector; tools=%v\", toolSchemaNames(fullReq.Tools))\n\t}\n}\n\nfunc TestBuildTokenEconomyStartsWithLeanToolSurface(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\", testutil.Turn{Text: \"done\"})\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n\n[[plugins]]\nname = \"mockmcp\"\ncommand = \"reasonix-missing-mockmcp\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"use the lean surface\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 1 {\n\t\tt.Fatalf(\"requests = %d, want 1\", len(reqs))\n\t}\n\treq := reqs[0]\n\twantTools := []string{\n\t\t\"ask\",\n\t\t\"bash\",\n\t\t\"bash_output\",\n\t\t\"complete_step\",\n\t\t\"connect_tool_source\",\n\t\t\"edit_file\",\n\t\t\"forget\",\n\t\t\"glob\",\n\t\t\"grep\",\n\t\t\"history\",\n\t\t\"kill_shell\",\n\t\t\"ls\",\n\t\t\"memory\",\n\t\t\"multi_edit\",\n\t\t\"read_file\",\n\t\t\"remember\",\n\t\t\"slash_command\",\n\t\t\"todo_write\",\n\t\t\"wait\",\n\t\t\"write_file\",\n\t}\n\tif got := toolSchemaNames(req.Tools); !reflect.DeepEqual(got, wantTools) {\n\t\tt.Fatalf(\"economy first request tool order changed\\ngot %v\\nwant %v\", got, wantTools)\n\t}\n\tfor _, want := range []string{\"connect_tool_source\", \"read_file\", \"grep\", \"edit_file\", \"bash\", \"slash_command\", \"ask\"} {\n\t\tif !requestHasTool(req, want) {\n\t\t\tt.Fatalf(\"economy first request missing tool %q; tools=%v\", want, toolSchemaNames(req.Tools))\n\t\t}\n\t}\n\tfor _, forbidden := range []string{\n\t\t\"web_fetch\", \"task\", \"run_skill\", \"read_skill\", \"install_skill\", \"install_source\",\n\t\t\"explore\", \"research\", \"review\", \"security_review\",\n\t\t\"lsp_definition\", \"lsp_references\", \"lsp_hover\", \"lsp_diagnostics\",\n\t} {\n\t\tif requestHasTool(req, forbidden) {\n\t\t\tt.Fatalf(\"economy first request should hide %q; tools=%v\", forbidden, toolSchemaNames(req.Tools))\n\t\t}\n\t}\n\tif requestHasToolPrefix(req, \"mcp__mockmcp\") {\n\t\tt.Fatalf(\"economy first request should not expose MCP placeholders; tools=%v\", toolSchemaNames(req.Tools))\n\t}\n\tsys := systemMessage(req.Messages)\n\tif !strings.Contains(sys, tokenEconomyPrompt) {\n\t\tt.Fatalf(\"token economy prompt missing from system message:\\n%s\", sys)\n\t}\n\tif strings.Contains(sys, \"# Skills\") || strings.Contains(sys, \"projskill\") {\n\t\tt.Fatalf(\"skills index should not be in economy system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildTokenEconomyConnectsWebFetchOnDemand(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"fetch later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasTool(reqs[0], \"web_fetch\") {\n\t\tt.Fatalf(\"first request should hide web_fetch; tools=%v\", toolSchemaNames(reqs[0].Tools))\n\t}\n\tif !requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"second request should expose web_fetch after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n}\n\nfunc TestBuildTokenEconomyPlanModeCanConnectWebFetch(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tctrl.SetPlanMode(true)\n\tif err := ctrl.Run(context.Background(), \"fetch later while planning\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif !requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"second request should expose web_fetch in plan economy mode; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" && strings.Contains(msg.Content, \"blocked:\") {\n\t\t\tt.Fatalf(\"connect_tool_source should not be blocked in plan mode, got:\\n%s\", msg.Content)\n\t\t}\n\t}\n}\n\nfunc TestBuildTokenEconomyWebFetchConnectorHonorsDisabledBuiltin(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[tools]\nenabled = [\"read_file\", \"grep\"]\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"fetch later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"disabled web_fetch should not be exposed after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tvar toolOutput string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" {\n\t\t\ttoolOutput += msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolOutput, \"web_fetch is disabled by [tools].enabled\") {\n\t\tt.Fatalf(\"connector should explain disabled web_fetch, got:\\n%s\", toolOutput)\n\t}\n}\n\nfunc TestBuildTokenEconomyConnectsSkillsOnDemand(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"skills\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"use skills later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tfor _, name := range []string{\"run_skill\", \"read_skill\", \"explore\"} {\n\t\tif requestHasTool(reqs[0], name) {\n\t\t\tt.Fatalf(\"first request should hide %q; tools=%v\", name, toolSchemaNames(reqs[0].Tools))\n\t\t}\n\t\tif !requestHasTool(reqs[1], name) {\n\t\t\tt.Fatalf(\"second request should expose %q after connect_tool_source; tools=%v\", name, toolSchemaNames(reqs[1].Tools))\n\t\t}\n\t}\n\tvar toolOutput string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" {\n\t\t\ttoolOutput += msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolOutput, \"projskill\") || !strings.Contains(toolOutput, \"# Skills\") {\n\t\tt.Fatalf(\"skills source result should include the skill index, got:\\n%s\", toolOutput)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{})\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(workspace*)\"]\n`)\n\n\tconst rule = \"Bash(go test ./...)\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleCreatesWorkspaceConfigOverUserConfig(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tworkspace := robustTempDir(t)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user)\"]\n`)\n\n\tconst rule = \"Edit(src/app.go)\"\n\tres := rememberPermissionRule(workspace, rule)\n\tif !res.Saved || res.Path != filepath.Join(workspace, \"reasonix.toml\") {\n\t\tt.Fatalf(\"remember result = %+v, want saved to workspace config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule was written to user config: %v\", userCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule missing from project config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user*)\"]\n`)\n\n\tconst rule = \"Bash(go env)\"\n\tres := rememberPermissionRule(\"\", rule)\n\tif !res.Saved || res.Path != userConfig {\n\t\tt.Fatalf(\"remember result = %+v, want saved to user source config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc TestRememberPermissionRuleSkipsRuleCoveredByExistingAllow(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test:*)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test ./...)\")\n\tif res.Saved || res.CoveredBy != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"remember result = %+v, want already covered\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif len(cfg.Permissions.Allow) != 1 || cfg.Permissions.Allow[0] != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"allow rules = %v, want only existing prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRulePrunesNarrowRulesWhenSavingBroaderRule(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test ./...)\", \"Bash(go build ./...)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test:*)\")\n\tif !res.Saved || res.CoveredBy != \"\" {\n\t\tt.Fatalf(\"remember result = %+v, want saved broader rule\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test ./...)\") {\n\t\tt.Fatalf(\"narrow go test rule should be pruned: %v\", cfg.Permissions.Allow)\n\t}\n\tif !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go build ./...)\") || !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test:*)\") {\n\t\tt.Fatalf(\"allow rules = %v, want unrelated exact plus prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuiltInMCPsYieldToExtraPluginNames(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, []string{\"time\", \"context7\"}, pluginSpecNames([]plugin.Spec{{Name: \"time\"}})...)\n\tif len(got) != 1 || got[0].Name != \"context7\" {\n\t\tt.Fatalf(\"built-in MCP entries with extra time = %+v, want only context7\", got)\n\t}\n}\n\nfunc TestBuiltInMCPDefaultsEnableOnlyTime(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, config.Default().BuiltInMCP.EnabledNames())\n\tif len(got) != 1 || got[0].Name != \"time\" {\n\t\tt.Fatalf(\"default built-in MCP entries = %+v, want only time\", got)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildCodegraphSetsShortDaemonIdleTimeout(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tenvOut := filepath.Join(dir, \"codegraph-idle-env\")\n\tt.Setenv(\"REASONIX_CODEGRAPH_HELPER_ENV_OUT\", envOut)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tdeadline := time.Now().Add(5 * time.Second)\n\tvar got []byte\n\tfor {\n\t\tgot, err = os.ReadFile(envOut)\n\t\tif err == nil {\n\t\t\tbreak\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"codegraph helper never recorded idle timeout env: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tif string(got) != codegraph.ReasonixDaemonIdleTimeoutMS {\n\t\tt.Fatalf(\"%s = %q; want %q\", codegraph.DaemonIdleTimeoutEnv, got, codegraph.ReasonixDaemonIdleTimeoutMS)\n\t}\n}\n\nfunc TestBuildWarmCodegraphIgnoresLegacyEagerTier(t *testing.T) {\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"fake launcher is a POSIX-sh script\")\n\t}\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.Mkdir(filepath.Join(dir, \".codegraph\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tlauncher := filepath.Join(dir, \"slow-codegraph\")\n\twriteFile(t, dir, \"slow-codegraph\", \"#!/bin/sh\\nif [ \\\"$1\\\" = serve ]; then sleep 5; fi\\n\")\n\tif err := os.Chmod(launcher, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"eager\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not block on warm codegraph with legacy eager tier: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildDefaultsToNearestGitRoot(t *testing.T) {\n\tisolateConfigHome(t)\n\troot := robustTempDir(t)\n\tif err := os.Mkdir(filepath.Join(root, \".git\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsubdir := filepath.Join(root, \"cmd\", \"tool\")\n\tif err := os.MkdirAll(subdir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\twriteFile(t, root, \"reasonix.toml\", `\ndefault_model = \"root-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"root-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\tt.Chdir(subdir)\n\n\tctrl, err := Build(context.Background(), Options{Model: \"root-model\"})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should load config from nearest git root: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\tif len(os.Args) >= 2 && os.Args[1] == \"serve\" {\n\t\tif out := os.Getenv(\"REASONIX_CODEGRAPH_HELPER_ENV_OUT\"); out != \"\" {\n\t\t\t_ = os.WriteFile(out, []byte(os.Getenv(\"CODEGRAPH_DAEMON_IDLE_TIMEOUT_MS\")), 0o644)\n\t\t}\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "b99e893191bb59305bacd11e3a7dcd2608f4004f": {"t": 1785750703.8477354, "n": 65714, "h": "c15991c2cf21ee4a", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/agent/testutil\"\n\t\"reasonix/internal/builtinmcp\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestBuildRegistersUsableHistoryAndMemoryRetrievalTools(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-retrieval-tool-test\"\nmodel = \"x\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"sessions\")\n\tif err := os.MkdirAll(sessionDir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tpast := agent.NewSession(\"\")\n\tpast.Add(provider.Message{Role: provider.RoleUser, Content: \"Should the history layer use vector embeddings?\"})\n\tpast.Add(provider.Message{Role: provider.RoleAssistant, Content: \"Decision: port lightweight BM25 history retrieval without a vector database.\"})\n\tif err := past.Save(filepath.Join(sessionDir, \"past.jsonl\")); err != nil {\n\t\tt.Fatalf(\"save past session: %v\", err)\n\t}\n\n\tstore := memory.StoreFor(config.MemoryUserDir(), dir)\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"synthesis-cache-policy\",\n\t\tDescription: \"Stable conclusions should be reused from memory\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t}); err != nil {\n\t\tt.Fatalf(\"save memory: %v\", err)\n\t}\n\n\tregisterBootRetrievalToolTestProvider()\n\tprov := testutil.NewMock(\"boot-retrieval-tool-test\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"history-1\", Name: \"history\", Arguments: `{\"operation\":\"search\",\"query\":\"BM25 vector database\",\"scope\":\"project\",\"limit\":5}`},\n\t\t\t{ID: \"memory-1\", Name: \"memory\", Arguments: `{\"operation\":\"search\",\"query\":\"synthesis cache stable conclusion\",\"limit\":5}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootRetrievalToolTestProvider(t, prov)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tfor _, forbidden := range []string{\n\t\t\"Decision: port lightweight BM25 history retrieval without a vector database.\",\n\t\t\"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t} {\n\t\tif strings.Contains(sys, forbidden) {\n\t\t\tt.Fatalf(\"retrieval content should stay behind on-demand tools, not enter the cache-stable system prompt:\\n%s\", sys)\n\t\t}\n\t}\n\n\tif err := ctrl.Run(context.Background(), \"recover past context\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) == 0 {\n\t\tt.Fatal(\"provider received no requests\")\n\t}\n\tfor _, want := range []string{\"history\", \"memory\", \"remember\", \"forget\"} {\n\t\tif !requestHasTool(reqs[0], want) {\n\t\t\tt.Fatalf(\"first request missing tool %q; tools=%v\", want, toolSchemaNames(reqs[0].Tools))\n\t\t}\n\t}\n\tassertToolOrder(t, reqs[0].Tools, []string{\"forget\", \"history\", \"memory\", \"remember\"})\n\n\ttoolResults := map[string]string{}\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolResults[msg.Name] += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolResults[\"history\"], \"port lightweight BM25 history retrieval\") {\n\t\tt.Fatalf(\"history tool result did not include saved session decision:\\n%s\", toolResults[\"history\"])\n\t}\n\tif !strings.Contains(toolResults[\"memory\"], \"synthesis-cache-policy\") ||\n\t\t!strings.Contains(toolResults[\"memory\"], \"stable conclusion\") {\n\t\tt.Fatalf(\"memory tool result did not include saved memory:\\n%s\", toolResults[\"memory\"])\n\t}\n}\n\nconst bootRetrievalToolTestProviderKind = \"boot-retrieval-tool-test\"\n\nvar (\n\tbootRetrievalToolTestProviderOnce sync.Once\n\tbootRetrievalToolTestProviderCurrent *testutil.MockProvider\n\tbootRetrievalToolTestProviderMu sync.Mutex\n)\n\nfunc registerBootRetrievalToolTestProvider() {\n\tbootRetrievalToolTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootRetrievalToolTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\t\tdefer bootRetrievalToolTestProviderMu.Unlock()\n\t\t\tif bootRetrievalToolTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot retrieval tool test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootRetrievalToolTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootRetrievalToolTestProvider(t *testing.T, p *testutil.MockProvider) {\n\tt.Helper()\n\tbootRetrievalToolTestProviderMu.Lock()\n\tbootRetrievalToolTestProviderCurrent = p\n\tbootRetrievalToolTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\tif bootRetrievalToolTestProviderCurrent == p {\n\t\t\tbootRetrievalToolTestProviderCurrent = nil\n\t\t}\n\t\tbootRetrievalToolTestProviderMu.Unlock()\n\t})\n}\n\nconst bootTokenProfileTestProviderKind = \"boot-token-profile-test\"\n\nvar (\n\tbootTokenProfileTestProviderOnce sync.Once\n\tbootTokenProfileTestProviderCurrent *testutil.MockProvider\n\tbootTokenProfileTestProviderMu sync.Mutex\n)\n\nfunc registerBootTokenProfileTestProvider() {\n\tbootTokenProfileTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootTokenProfileTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootTokenProfileTestProviderMu.Lock()\n\t\t\tdefer bootTokenProfileTestProviderMu.Unlock()\n\t\t\tif bootTokenProfileTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot token profile test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootTokenProfileTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootTokenProfileTestProvider(t *testing.T, p *testutil.MockProvider) {\n\tt.Helper()\n\tbootTokenProfileTestProviderMu.Lock()\n\tbootTokenProfileTestProviderCurrent = p\n\tbootTokenProfileTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootTokenProfileTestProviderMu.Lock()\n\t\tif bootTokenProfileTestProviderCurrent == p {\n\t\t\tbootTokenProfileTestProviderCurrent = nil\n\t\t}\n\t\tbootTokenProfileTestProviderMu.Unlock()\n\t})\n}\n\nfunc requestHasTool(req provider.Request, name string) bool {\n\tfor _, schema := range req.Tools {\n\t\tif schema.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc requestHasToolPrefix(req provider.Request, prefix string) bool {\n\tfor _, schema := range req.Tools {\n\t\tif strings.HasPrefix(schema.Name, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc toolSchemaNames(tools []provider.ToolSchema) []string {\n\tnames := make([]string, 0, len(tools))\n\tfor _, schema := range tools {\n\t\tnames = append(names, schema.Name)\n\t}\n\treturn names\n}\n\nfunc assertToolOrder(t *testing.T, tools []provider.ToolSchema, want []string) {\n\tt.Helper()\n\tnames := toolSchemaNames(tools)\n\tnext := 0\n\tfor _, name := range names {\n\t\tif next < len(want) && name == want[next] {\n\t\t\tnext++\n\t\t}\n\t}\n\tif next != len(want) {\n\t\tt.Fatalf(\"tool order changed; provider-visible tool schema order affects prompt-cache shape.\\nwant subsequence: %v\\n got: %v\", want, names)\n\t}\n}\n\nfunc firstTokenProfileRequest(t *testing.T, tokenMode string) provider.Request {\n\tt.Helper()\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-profile\", testutil.Turn{Text: \"done\"})\n\tsetBootTokenProfileTestProvider(t, prov)\n\n\topts := Options{Sink: event.Discard}\n\tif tokenMode != \"\" {\n\t\topts.TokenMode = tokenMode\n\t}\n\tctrl, err := Build(context.Background(), opts)\n\tif err != nil {\n\t\tt.Fatalf(\"Build(%q): %v\", tokenMode, err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"capture request prefix\"); err != nil {\n\t\tt.Fatalf(\"Run(%q): %v\", tokenMode, err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 1 {\n\t\tt.Fatalf(\"requests(%q) = %d, want 1\", tokenMode, len(reqs))\n\t}\n\treturn reqs[0]\n}\n\nfunc TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"first Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\tprov.setContinueRef(ref)\n\n\tif err := ctrl.Run(context.Background(), \"continue review\"); err != nil {\n\t\tt.Fatalf(\"second Run: %v\", err)\n\t}\n\tstore := agent.NewSubagentStore(filepath.Join(config.SessionDir(), \"subagents\"))\n\tmeta, err := store.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta: %v\", err)\n\t}\n\tif meta.Status != agent.SubagentFailed {\n\t\tt.Fatalf(\"status = %q, want failed\", meta.Status)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tsess, err := agent.LoadSession(filepath.Join(config.SessionDir(), \"subagents\", ref+\".jsonl\"))\n\tif err != nil {\n\t\tt.Fatalf(\"LoadSession: %v\", err)\n\t}\n\tmsgs := sess.Snapshot()\n\tif len(msgs) != 4 || msgs[1].Content != \"first skill task\" || msgs[2].Content != \"first skill answer\" || msgs[3].Content != \"second skill task\" {\n\t\tt.Fatalf(\"failed skill transcript = %+v, want first task/answer plus second task\", msgs)\n\t}\n}\n\nfunc TestBuildSubagentStoreHonorsSessionDirOverride(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\n\toverrideStore := agent.NewSubagentStore(filepath.Join(sessionDir, \"subagents\"))\n\tmeta, err := overrideStore.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta from override dir: %v\", err)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \"subagents\", ref+\".meta.json\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"subagent metadata should not be written to global session dir, stat err = %v\", err)\n\t}\n}\n\nconst bootSubagentTestProviderKind = \"boot-subagent-test\"\n\nvar (\n\tbootSubagentTestProviderOnce sync.Once\n\tbootSubagentTestProviderCurrent *bootSubagentTestProvider\n\tbootSubagentTestProviderMu sync.Mutex\n)\n\nfunc registerBootSubagentTestProvider() {\n\tbootSubagentTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootSubagentTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootSubagentTestProviderMu.Lock()\n\t\t\tdefer bootSubagentTestProviderMu.Unlock()\n\t\t\tif bootSubagentTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot subagent test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootSubagentTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootSubagentTestProvider(t *testing.T, p *bootSubagentTestProvider) {\n\tt.Helper()\n\tbootSubagentTestProviderMu.Lock()\n\tbootSubagentTestProviderCurrent = p\n\tbootSubagentTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootSubagentTestProviderMu.Lock()\n\t\tif bootSubagentTestProviderCurrent == p {\n\t\t\tbootSubagentTestProviderCurrent = nil\n\t\t}\n\t\tbootSubagentTestProviderMu.Unlock()\n\t})\n}\n\ntype bootSubagentTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n\tcontinueRef string\n}\n\nfunc (p *bootSubagentTestProvider) Name() string { return \"boot-subagent-test\" }\n\nfunc (p *bootSubagentTestProvider) setContinueRef(ref string) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tp.continueRef = ref\n}\n\nfunc (p *bootSubagentTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tref := p.continueRef\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-1\", Name: \"review\", Arguments: `{\"task\":\"first skill task\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"first skill answer\"}, {Type: provider.ChunkDone}}\n\tcase 2:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent first done\"}, {Type: provider.ChunkDone}}\n\tcase 3:\n\t\targs, _ := json.Marshal(map[string]string{\"task\": \"second skill task\", \"continue_from\": ref})\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-2\", Name: \"review\", Arguments: string(args)}}}\n\tcase 4:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: errors.New(\"subagent skill failed\")}}\n\tcase 5:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent second done\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf(\"unexpected provider call %d\", call)}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc subagentRefFromHistory(t *testing.T, msgs []provider.Message) string {\n\tt.Helper()\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, line := range strings.Split(msg.Content, \"\\n\") {\n\t\t\tif strings.HasPrefix(line, \"Subagent reference: \") {\n\t\t\t\treturn strings.TrimSpace(strings.TrimPrefix(line, \"Subagent reference: \"))\n\t\t\t}\n\t\t}\n\t}\n\tt.Fatalf(\"no subagent reference in history: %+v\", msgs)\n\treturn \"\"\n}\n\n// TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath reproduces headless\n// `reasonix run`: a controller built via Build with NO SetSessionPath (exactly\n// what internal/cli.runAgent does) must still be able to run a `task` sub-agent.\n// Before the ephemeral fallback this failed with \"parent session is required\".\nfunc TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterHeadlessTaskTestProvider()\n\tprov := &headlessTaskTestProvider{}\n\tsetHeadlessTaskTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-headless-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// Deliberately NOT calling SetSessionPath — this is the headless run path.\n\tif err := ctrl.Run(context.Background(), \"use a task subagent\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tif got := ctrl.SessionPath(); got != \"\" {\n\t\tt.Fatalf(\"headless run should keep an empty session path, got %q\", got)\n\t}\n\n\tvar toolContent string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolContent += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif strings.Contains(toolContent, \"parent session is required\") {\n\t\tt.Fatalf(\"task subagent failed in headless run mode: %s\", toolContent)\n\t}\n\tif !strings.Contains(toolContent, \"subagent answer\") {\n\t\tt.Fatalf(\"task tool result = %q, want sub-agent answer\", toolContent)\n\t}\n\tif strings.Contains(toolContent, \"Subagent reference\") {\n\t\tt.Fatalf(\"ephemeral headless run should not persist a transcript reference: %s\", toolContent)\n\t}\n}\n\nconst headlessTaskTestProviderKind = \"boot-headless-test\"\n\nvar (\n\theadlessTaskTestProviderOnce sync.Once\n\theadlessTaskTestProviderCurrent *headlessTaskTestProvider\n\theadlessTaskTestProviderMu sync.Mutex\n)\n\nfunc registerHeadlessTaskTestProvider() {\n\theadlessTaskTestProviderOnce.Do(func() {\n\t\tprovider.Register(headlessTaskTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\theadlessTaskTestProviderMu.Lock()\n\t\t\tdefer headlessTaskTestProviderMu.Unlock()\n\t\t\tif headlessTaskTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"headless task test provider is not installed\")\n\t\t\t}\n\t\t\treturn headlessTaskTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setHeadlessTaskTestProvider(t *testing.T, p *headlessTaskTestProvider) {\n\tt.Helper()\n\theadlessTaskTestProviderMu.Lock()\n\theadlessTaskTestProviderCurrent = p\n\theadlessTaskTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\theadlessTaskTestProviderMu.Lock()\n\t\tif headlessTaskTestProviderCurrent == p {\n\t\t\theadlessTaskTestProviderCurrent = nil\n\t\t}\n\t\theadlessTaskTestProviderMu.Unlock()\n\t})\n}\n\ntype headlessTaskTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n}\n\nfunc (p *headlessTaskTestProvider) Name() string { return \"boot-headless-test\" }\n\nfunc (p *headlessTaskTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"task-1\", Name: \"task\", Arguments: `{\"prompt\":\"find callers\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"subagent answer\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent done\"}, {Type: provider.ChunkDone}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\nfunc TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4-flash\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want high from DeepSeek model capability\", got)\n\t}\n\tthinking, ok := gotReq[\"thinking\"].(map[string]any)\n\tif !ok || thinking[\"type\"] != \"enabled\" {\n\t\tt.Fatalf(\"thinking = %#v, want enabled\", gotReq[\"thinking\"])\n\t}\n}\n\nfunc TestBuildHonorsSessionDirOverride(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.SessionDir(); got != sessionDir {\n\t\tt.Fatalf(\"SessionDir() = %q, want override %q\", got, sessionDir)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildTokenFullMatchesDefaultRequestPrefix(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tdefaultReq := firstTokenProfileRequest(t, \"\")\n\tfullReq := firstTokenProfileRequest(t, TokenModeFull)\n\n\tif got, want := systemMessage(defaultReq.Messages), systemMessage(fullReq.Messages); got != want {\n\t\tt.Fatalf(\"explicit full mode changed the system prompt\\n--- default ---\\n%s\\n--- full ---\\n%s\", got, want)\n\t}\n\tif strings.Contains(systemMessage(fullReq.Messages), tokenEconomyPrompt) {\n\t\tt.Fatalf(\"full mode system prompt should not include token economy prompt:\\n%s\", systemMessage(fullReq.Messages))\n\t}\n\tif !strings.Contains(systemMessage(fullReq.Messages), \"# Skills\") || !strings.Contains(systemMessage(fullReq.Messages), \"projskill\") {\n\t\tt.Fatalf(\"full mode should preserve the skills index in the system prompt:\\n%s\", systemMessage(fullReq.Messages))\n\t}\n\tif got, want := toolSchemaNames(fullReq.Tools), toolSchemaNames(defaultReq.Tools); !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"explicit full mode changed tool schema order\\nfull=%v\\ndefault=%v\", got, want)\n\t}\n\tif !reflect.DeepEqual(fullReq.Tools, defaultReq.Tools) {\n\t\tt.Fatalf(\"explicit full mode changed provider-visible tool schemas; names=%v\", toolSchemaNames(fullReq.Tools))\n\t}\n\tif requestHasTool(fullReq, \"connect_tool_source\") {\n\t\tt.Fatalf(\"full mode should not expose economy connector; tools=%v\", toolSchemaNames(fullReq.Tools))\n\t}\n}\n\nfunc TestBuildTokenEconomyStartsWithLeanToolSurface(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\", testutil.Turn{Text: \"done\"})\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n\n[[plugins]]\nname = \"mockmcp\"\ncommand = \"reasonix-missing-mockmcp\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"use the lean surface\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 1 {\n\t\tt.Fatalf(\"requests = %d, want 1\", len(reqs))\n\t}\n\treq := reqs[0]\n\twantTools := []string{\n\t\t\"ask\",\n\t\t\"bash\",\n\t\t\"bash_output\",\n\t\t\"complete_step\",\n\t\t\"connect_tool_source\",\n\t\t\"edit_file\",\n\t\t\"forget\",\n\t\t\"glob\",\n\t\t\"grep\",\n\t\t\"history\",\n\t\t\"kill_shell\",\n\t\t\"ls\",\n\t\t\"memory\",\n\t\t\"multi_edit\",\n\t\t\"read_file\",\n\t\t\"remember\",\n\t\t\"slash_command\",\n\t\t\"todo_write\",\n\t\t\"wait\",\n\t\t\"write_file\",\n\t}\n\tif got := toolSchemaNames(req.Tools); !reflect.DeepEqual(got, wantTools) {\n\t\tt.Fatalf(\"economy first request tool order changed\\ngot %v\\nwant %v\", got, wantTools)\n\t}\n\tfor _, want := range []string{\"connect_tool_source\", \"read_file\", \"grep\", \"edit_file\", \"bash\", \"slash_command\", \"ask\"} {\n\t\tif !requestHasTool(req, want) {\n\t\t\tt.Fatalf(\"economy first request missing tool %q; tools=%v\", want, toolSchemaNames(req.Tools))\n\t\t}\n\t}\n\tfor _, forbidden := range []string{\n\t\t\"web_fetch\", \"task\", \"run_skill\", \"read_skill\", \"install_skill\", \"install_source\",\n\t\t\"explore\", \"research\", \"review\", \"security_review\",\n\t\t\"lsp_definition\", \"lsp_references\", \"lsp_hover\", \"lsp_diagnostics\",\n\t} {\n\t\tif requestHasTool(req, forbidden) {\n\t\t\tt.Fatalf(\"economy first request should hide %q; tools=%v\", forbidden, toolSchemaNames(req.Tools))\n\t\t}\n\t}\n\tif requestHasToolPrefix(req, \"mcp__mockmcp\") {\n\t\tt.Fatalf(\"economy first request should not expose MCP placeholders; tools=%v\", toolSchemaNames(req.Tools))\n\t}\n\tsys := systemMessage(req.Messages)\n\tif !strings.Contains(sys, tokenEconomyPrompt) {\n\t\tt.Fatalf(\"token economy prompt missing from system message:\\n%s\", sys)\n\t}\n\tif strings.Contains(sys, \"# Skills\") || strings.Contains(sys, \"projskill\") {\n\t\tt.Fatalf(\"skills index should not be in economy system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildTokenEconomyConnectsWebFetchOnDemand(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"fetch later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasTool(reqs[0], \"web_fetch\") {\n\t\tt.Fatalf(\"first request should hide web_fetch; tools=%v\", toolSchemaNames(reqs[0].Tools))\n\t}\n\tif !requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"second request should expose web_fetch after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n}\n\nfunc TestBuildTokenEconomyPlanModeCanConnectWebFetch(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tctrl.SetPlanMode(true)\n\tif err := ctrl.Run(context.Background(), \"fetch later while planning\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif !requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"second request should expose web_fetch in plan economy mode; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" && strings.Contains(msg.Content, \"blocked:\") {\n\t\t\tt.Fatalf(\"connect_tool_source should not be blocked in plan mode, got:\\n%s\", msg.Content)\n\t\t}\n\t}\n}\n\nfunc TestBuildTokenEconomyWebFetchConnectorHonorsDisabledBuiltin(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[tools]\nenabled = [\"read_file\", \"grep\"]\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"fetch later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"disabled web_fetch should not be exposed after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tvar toolOutput string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" {\n\t\t\ttoolOutput += msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolOutput, \"web_fetch is disabled by [tools].enabled\") {\n\t\tt.Fatalf(\"connector should explain disabled web_fetch, got:\\n%s\", toolOutput)\n\t}\n}\n\nfunc TestBuildTokenEconomyConnectsSkillsOnDemand(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"skills\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"use skills later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tfor _, name := range []string{\"run_skill\", \"read_skill\", \"explore\"} {\n\t\tif requestHasTool(reqs[0], name) {\n\t\t\tt.Fatalf(\"first request should hide %q; tools=%v\", name, toolSchemaNames(reqs[0].Tools))\n\t\t}\n\t\tif !requestHasTool(reqs[1], name) {\n\t\t\tt.Fatalf(\"second request should expose %q after connect_tool_source; tools=%v\", name, toolSchemaNames(reqs[1].Tools))\n\t\t}\n\t}\n\tvar toolOutput string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" {\n\t\t\ttoolOutput += msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolOutput, \"projskill\") || !strings.Contains(toolOutput, \"# Skills\") {\n\t\tt.Fatalf(\"skills source result should include the skill index, got:\\n%s\", toolOutput)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{})\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(workspace*)\"]\n`)\n\n\tconst rule = \"Bash(go test ./...)\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleCreatesWorkspaceConfigOverUserConfig(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tworkspace := robustTempDir(t)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user)\"]\n`)\n\n\tconst rule = \"Edit(src/app.go)\"\n\tres := rememberPermissionRule(workspace, rule)\n\tif !res.Saved || res.Path != filepath.Join(workspace, \"reasonix.toml\") {\n\t\tt.Fatalf(\"remember result = %+v, want saved to workspace config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule was written to user config: %v\", userCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule missing from project config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user*)\"]\n`)\n\n\tconst rule = \"Bash(go env)\"\n\tres := rememberPermissionRule(\"\", rule)\n\tif !res.Saved || res.Path != userConfig {\n\t\tt.Fatalf(\"remember result = %+v, want saved to user source config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc TestRememberPermissionRuleSkipsRuleCoveredByExistingAllow(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test:*)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test ./...)\")\n\tif res.Saved || res.CoveredBy != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"remember result = %+v, want already covered\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif len(cfg.Permissions.Allow) != 1 || cfg.Permissions.Allow[0] != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"allow rules = %v, want only existing prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRulePrunesNarrowRulesWhenSavingBroaderRule(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test ./...)\", \"Bash(go build ./...)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test:*)\")\n\tif !res.Saved || res.CoveredBy != \"\" {\n\t\tt.Fatalf(\"remember result = %+v, want saved broader rule\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test ./...)\") {\n\t\tt.Fatalf(\"narrow go test rule should be pruned: %v\", cfg.Permissions.Allow)\n\t}\n\tif !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go build ./...)\") || !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test:*)\") {\n\t\tt.Fatalf(\"allow rules = %v, want unrelated exact plus prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuiltInMCPsYieldToExtraPluginNames(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, []string{\"time\", \"context7\"}, pluginSpecNames([]plugin.Spec{{Name: \"time\"}})...)\n\tif len(got) != 1 || got[0].Name != \"context7\" {\n\t\tt.Fatalf(\"built-in MCP entries with extra time = %+v, want only context7\", got)\n\t}\n}\n\nfunc TestBuiltInMCPDefaultsEnableOnlyTime(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, config.Default().BuiltInMCP.EnabledNames())\n\tif len(got) != 1 || got[0].Name != \"time\" {\n\t\tt.Fatalf(\"default built-in MCP entries = %+v, want only time\", got)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildWarmCodegraphIgnoresLegacyEagerTier(t *testing.T) {\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"fake launcher is a POSIX-sh script\")\n\t}\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.Mkdir(filepath.Join(dir, \".codegraph\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tlauncher := filepath.Join(dir, \"slow-codegraph\")\n\twriteFile(t, dir, \"slow-codegraph\", \"#!/bin/sh\\nif [ \\\"$1\\\" = serve ]; then sleep 5; fi\\n\")\n\tif err := os.Chmod(launcher, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"eager\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not block on warm codegraph with legacy eager tier: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildDefaultsToNearestGitRoot(t *testing.T) {\n\tisolateConfigHome(t)\n\troot := robustTempDir(t)\n\tif err := os.Mkdir(filepath.Join(root, \".git\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsubdir := filepath.Join(root, \"cmd\", \"tool\")\n\tif err := os.MkdirAll(subdir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\twriteFile(t, root, \"reasonix.toml\", `\ndefault_model = \"root-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"root-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\tt.Chdir(subdir)\n\n\tctrl, err := Build(context.Background(), Options{Model: \"root-model\"})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should load config from nearest git root: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "8572ae50be3a8162f56366a5b7f2790ebe28182d": {"t": 1785750705.1982713, "n": 64049, "h": "f2354cca9dce5ccf", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"reflect\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/agent/testutil\"\n\t\"reasonix/internal/builtinmcp\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestBuildRegistersUsableHistoryAndMemoryRetrievalTools(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-retrieval-tool-test\"\nmodel = \"x\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"sessions\")\n\tif err := os.MkdirAll(sessionDir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tpast := agent.NewSession(\"\")\n\tpast.Add(provider.Message{Role: provider.RoleUser, Content: \"Should the history layer use vector embeddings?\"})\n\tpast.Add(provider.Message{Role: provider.RoleAssistant, Content: \"Decision: port lightweight BM25 history retrieval without a vector database.\"})\n\tif err := past.Save(filepath.Join(sessionDir, \"past.jsonl\")); err != nil {\n\t\tt.Fatalf(\"save past session: %v\", err)\n\t}\n\n\tstore := memory.StoreFor(config.MemoryUserDir(), dir)\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"synthesis-cache-policy\",\n\t\tDescription: \"Stable conclusions should be reused from memory\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t}); err != nil {\n\t\tt.Fatalf(\"save memory: %v\", err)\n\t}\n\n\tregisterBootRetrievalToolTestProvider()\n\tprov := testutil.NewMock(\"boot-retrieval-tool-test\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"history-1\", Name: \"history\", Arguments: `{\"operation\":\"search\",\"query\":\"BM25 vector database\",\"scope\":\"project\",\"limit\":5}`},\n\t\t\t{ID: \"memory-1\", Name: \"memory\", Arguments: `{\"operation\":\"search\",\"query\":\"synthesis cache stable conclusion\",\"limit\":5}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootRetrievalToolTestProvider(t, prov)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tfor _, forbidden := range []string{\n\t\t\"Decision: port lightweight BM25 history retrieval without a vector database.\",\n\t\t\"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t} {\n\t\tif strings.Contains(sys, forbidden) {\n\t\t\tt.Fatalf(\"retrieval content should stay behind on-demand tools, not enter the cache-stable system prompt:\\n%s\", sys)\n\t\t}\n\t}\n\n\tif err := ctrl.Run(context.Background(), \"recover past context\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) == 0 {\n\t\tt.Fatal(\"provider received no requests\")\n\t}\n\tfor _, want := range []string{\"history\", \"memory\", \"remember\", \"forget\"} {\n\t\tif !requestHasTool(reqs[0], want) {\n\t\t\tt.Fatalf(\"first request missing tool %q; tools=%v\", want, toolSchemaNames(reqs[0].Tools))\n\t\t}\n\t}\n\tassertToolOrder(t, reqs[0].Tools, []string{\"forget\", \"history\", \"memory\", \"remember\"})\n\n\ttoolResults := map[string]string{}\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolResults[msg.Name] += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolResults[\"history\"], \"port lightweight BM25 history retrieval\") {\n\t\tt.Fatalf(\"history tool result did not include saved session decision:\\n%s\", toolResults[\"history\"])\n\t}\n\tif !strings.Contains(toolResults[\"memory\"], \"synthesis-cache-policy\") ||\n\t\t!strings.Contains(toolResults[\"memory\"], \"stable conclusion\") {\n\t\tt.Fatalf(\"memory tool result did not include saved memory:\\n%s\", toolResults[\"memory\"])\n\t}\n}\n\nconst bootRetrievalToolTestProviderKind = \"boot-retrieval-tool-test\"\n\nvar (\n\tbootRetrievalToolTestProviderOnce sync.Once\n\tbootRetrievalToolTestProviderCurrent *testutil.MockProvider\n\tbootRetrievalToolTestProviderMu sync.Mutex\n)\n\nfunc registerBootRetrievalToolTestProvider() {\n\tbootRetrievalToolTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootRetrievalToolTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\t\tdefer bootRetrievalToolTestProviderMu.Unlock()\n\t\t\tif bootRetrievalToolTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot retrieval tool test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootRetrievalToolTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootRetrievalToolTestProvider(t *testing.T, p *testutil.MockProvider) {\n\tt.Helper()\n\tbootRetrievalToolTestProviderMu.Lock()\n\tbootRetrievalToolTestProviderCurrent = p\n\tbootRetrievalToolTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\tif bootRetrievalToolTestProviderCurrent == p {\n\t\t\tbootRetrievalToolTestProviderCurrent = nil\n\t\t}\n\t\tbootRetrievalToolTestProviderMu.Unlock()\n\t})\n}\n\nconst bootTokenProfileTestProviderKind = \"boot-token-profile-test\"\n\nvar (\n\tbootTokenProfileTestProviderOnce sync.Once\n\tbootTokenProfileTestProviderCurrent *testutil.MockProvider\n\tbootTokenProfileTestProviderMu sync.Mutex\n)\n\nfunc registerBootTokenProfileTestProvider() {\n\tbootTokenProfileTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootTokenProfileTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootTokenProfileTestProviderMu.Lock()\n\t\t\tdefer bootTokenProfileTestProviderMu.Unlock()\n\t\t\tif bootTokenProfileTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot token profile test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootTokenProfileTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootTokenProfileTestProvider(t *testing.T, p *testutil.MockProvider) {\n\tt.Helper()\n\tbootTokenProfileTestProviderMu.Lock()\n\tbootTokenProfileTestProviderCurrent = p\n\tbootTokenProfileTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootTokenProfileTestProviderMu.Lock()\n\t\tif bootTokenProfileTestProviderCurrent == p {\n\t\t\tbootTokenProfileTestProviderCurrent = nil\n\t\t}\n\t\tbootTokenProfileTestProviderMu.Unlock()\n\t})\n}\n\nfunc requestHasTool(req provider.Request, name string) bool {\n\tfor _, schema := range req.Tools {\n\t\tif schema.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc requestHasToolPrefix(req provider.Request, prefix string) bool {\n\tfor _, schema := range req.Tools {\n\t\tif strings.HasPrefix(schema.Name, prefix) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc toolSchemaNames(tools []provider.ToolSchema) []string {\n\tnames := make([]string, 0, len(tools))\n\tfor _, schema := range tools {\n\t\tnames = append(names, schema.Name)\n\t}\n\treturn names\n}\n\nfunc assertToolOrder(t *testing.T, tools []provider.ToolSchema, want []string) {\n\tt.Helper()\n\tnames := toolSchemaNames(tools)\n\tnext := 0\n\tfor _, name := range names {\n\t\tif next < len(want) && name == want[next] {\n\t\t\tnext++\n\t\t}\n\t}\n\tif next != len(want) {\n\t\tt.Fatalf(\"tool order changed; provider-visible tool schema order affects prompt-cache shape.\\nwant subsequence: %v\\n got: %v\", want, names)\n\t}\n}\n\nfunc firstTokenProfileRequest(t *testing.T, tokenMode string) provider.Request {\n\tt.Helper()\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-profile\", testutil.Turn{Text: \"done\"})\n\tsetBootTokenProfileTestProvider(t, prov)\n\n\topts := Options{Sink: event.Discard}\n\tif tokenMode != \"\" {\n\t\topts.TokenMode = tokenMode\n\t}\n\tctrl, err := Build(context.Background(), opts)\n\tif err != nil {\n\t\tt.Fatalf(\"Build(%q): %v\", tokenMode, err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"capture request prefix\"); err != nil {\n\t\tt.Fatalf(\"Run(%q): %v\", tokenMode, err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 1 {\n\t\tt.Fatalf(\"requests(%q) = %d, want 1\", tokenMode, len(reqs))\n\t}\n\treturn reqs[0]\n}\n\nfunc TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"first Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\tprov.setContinueRef(ref)\n\n\tif err := ctrl.Run(context.Background(), \"continue review\"); err != nil {\n\t\tt.Fatalf(\"second Run: %v\", err)\n\t}\n\tstore := agent.NewSubagentStore(filepath.Join(config.SessionDir(), \"subagents\"))\n\tmeta, err := store.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta: %v\", err)\n\t}\n\tif meta.Status != agent.SubagentFailed {\n\t\tt.Fatalf(\"status = %q, want failed\", meta.Status)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tsess, err := agent.LoadSession(filepath.Join(config.SessionDir(), \"subagents\", ref+\".jsonl\"))\n\tif err != nil {\n\t\tt.Fatalf(\"LoadSession: %v\", err)\n\t}\n\tmsgs := sess.Snapshot()\n\tif len(msgs) != 4 || msgs[1].Content != \"first skill task\" || msgs[2].Content != \"first skill answer\" || msgs[3].Content != \"second skill task\" {\n\t\tt.Fatalf(\"failed skill transcript = %+v, want first task/answer plus second task\", msgs)\n\t}\n}\n\nconst bootSubagentTestProviderKind = \"boot-subagent-test\"\n\nvar (\n\tbootSubagentTestProviderOnce sync.Once\n\tbootSubagentTestProviderCurrent *bootSubagentTestProvider\n\tbootSubagentTestProviderMu sync.Mutex\n)\n\nfunc registerBootSubagentTestProvider() {\n\tbootSubagentTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootSubagentTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootSubagentTestProviderMu.Lock()\n\t\t\tdefer bootSubagentTestProviderMu.Unlock()\n\t\t\tif bootSubagentTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot subagent test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootSubagentTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootSubagentTestProvider(t *testing.T, p *bootSubagentTestProvider) {\n\tt.Helper()\n\tbootSubagentTestProviderMu.Lock()\n\tbootSubagentTestProviderCurrent = p\n\tbootSubagentTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootSubagentTestProviderMu.Lock()\n\t\tif bootSubagentTestProviderCurrent == p {\n\t\t\tbootSubagentTestProviderCurrent = nil\n\t\t}\n\t\tbootSubagentTestProviderMu.Unlock()\n\t})\n}\n\ntype bootSubagentTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n\tcontinueRef string\n}\n\nfunc (p *bootSubagentTestProvider) Name() string { return \"boot-subagent-test\" }\n\nfunc (p *bootSubagentTestProvider) setContinueRef(ref string) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tp.continueRef = ref\n}\n\nfunc (p *bootSubagentTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tref := p.continueRef\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-1\", Name: \"review\", Arguments: `{\"task\":\"first skill task\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"first skill answer\"}, {Type: provider.ChunkDone}}\n\tcase 2:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent first done\"}, {Type: provider.ChunkDone}}\n\tcase 3:\n\t\targs, _ := json.Marshal(map[string]string{\"task\": \"second skill task\", \"continue_from\": ref})\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-2\", Name: \"review\", Arguments: string(args)}}}\n\tcase 4:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: errors.New(\"subagent skill failed\")}}\n\tcase 5:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent second done\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf(\"unexpected provider call %d\", call)}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc subagentRefFromHistory(t *testing.T, msgs []provider.Message) string {\n\tt.Helper()\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, line := range strings.Split(msg.Content, \"\\n\") {\n\t\t\tif strings.HasPrefix(line, \"Subagent reference: \") {\n\t\t\t\treturn strings.TrimSpace(strings.TrimPrefix(line, \"Subagent reference: \"))\n\t\t\t}\n\t\t}\n\t}\n\tt.Fatalf(\"no subagent reference in history: %+v\", msgs)\n\treturn \"\"\n}\n\n// TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath reproduces headless\n// `reasonix run`: a controller built via Build with NO SetSessionPath (exactly\n// what internal/cli.runAgent does) must still be able to run a `task` sub-agent.\n// Before the ephemeral fallback this failed with \"parent session is required\".\nfunc TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterHeadlessTaskTestProvider()\n\tprov := &headlessTaskTestProvider{}\n\tsetHeadlessTaskTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-headless-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// Deliberately NOT calling SetSessionPath — this is the headless run path.\n\tif err := ctrl.Run(context.Background(), \"use a task subagent\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tif got := ctrl.SessionPath(); got != \"\" {\n\t\tt.Fatalf(\"headless run should keep an empty session path, got %q\", got)\n\t}\n\n\tvar toolContent string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolContent += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif strings.Contains(toolContent, \"parent session is required\") {\n\t\tt.Fatalf(\"task subagent failed in headless run mode: %s\", toolContent)\n\t}\n\tif !strings.Contains(toolContent, \"subagent answer\") {\n\t\tt.Fatalf(\"task tool result = %q, want sub-agent answer\", toolContent)\n\t}\n\tif strings.Contains(toolContent, \"Subagent reference\") {\n\t\tt.Fatalf(\"ephemeral headless run should not persist a transcript reference: %s\", toolContent)\n\t}\n}\n\nconst headlessTaskTestProviderKind = \"boot-headless-test\"\n\nvar (\n\theadlessTaskTestProviderOnce sync.Once\n\theadlessTaskTestProviderCurrent *headlessTaskTestProvider\n\theadlessTaskTestProviderMu sync.Mutex\n)\n\nfunc registerHeadlessTaskTestProvider() {\n\theadlessTaskTestProviderOnce.Do(func() {\n\t\tprovider.Register(headlessTaskTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\theadlessTaskTestProviderMu.Lock()\n\t\t\tdefer headlessTaskTestProviderMu.Unlock()\n\t\t\tif headlessTaskTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"headless task test provider is not installed\")\n\t\t\t}\n\t\t\treturn headlessTaskTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setHeadlessTaskTestProvider(t *testing.T, p *headlessTaskTestProvider) {\n\tt.Helper()\n\theadlessTaskTestProviderMu.Lock()\n\theadlessTaskTestProviderCurrent = p\n\theadlessTaskTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\theadlessTaskTestProviderMu.Lock()\n\t\tif headlessTaskTestProviderCurrent == p {\n\t\t\theadlessTaskTestProviderCurrent = nil\n\t\t}\n\t\theadlessTaskTestProviderMu.Unlock()\n\t})\n}\n\ntype headlessTaskTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n}\n\nfunc (p *headlessTaskTestProvider) Name() string { return \"boot-headless-test\" }\n\nfunc (p *headlessTaskTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"task-1\", Name: \"task\", Arguments: `{\"prompt\":\"find callers\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"subagent answer\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent done\"}, {Type: provider.ChunkDone}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\nfunc TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4-flash\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want high from DeepSeek model capability\", got)\n\t}\n\tthinking, ok := gotReq[\"thinking\"].(map[string]any)\n\tif !ok || thinking[\"type\"] != \"enabled\" {\n\t\tt.Fatalf(\"thinking = %#v, want enabled\", gotReq[\"thinking\"])\n\t}\n}\n\nfunc TestBuildHonorsSessionDirOverride(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.SessionDir(); got != sessionDir {\n\t\tt.Fatalf(\"SessionDir() = %q, want override %q\", got, sessionDir)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildTokenFullMatchesDefaultRequestPrefix(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tdefaultReq := firstTokenProfileRequest(t, \"\")\n\tfullReq := firstTokenProfileRequest(t, TokenModeFull)\n\n\tif got, want := systemMessage(defaultReq.Messages), systemMessage(fullReq.Messages); got != want {\n\t\tt.Fatalf(\"explicit full mode changed the system prompt\\n--- default ---\\n%s\\n--- full ---\\n%s\", got, want)\n\t}\n\tif strings.Contains(systemMessage(fullReq.Messages), tokenEconomyPrompt) {\n\t\tt.Fatalf(\"full mode system prompt should not include token economy prompt:\\n%s\", systemMessage(fullReq.Messages))\n\t}\n\tif !strings.Contains(systemMessage(fullReq.Messages), \"# Skills\") || !strings.Contains(systemMessage(fullReq.Messages), \"projskill\") {\n\t\tt.Fatalf(\"full mode should preserve the skills index in the system prompt:\\n%s\", systemMessage(fullReq.Messages))\n\t}\n\tif got, want := toolSchemaNames(fullReq.Tools), toolSchemaNames(defaultReq.Tools); !reflect.DeepEqual(got, want) {\n\t\tt.Fatalf(\"explicit full mode changed tool schema order\\nfull=%v\\ndefault=%v\", got, want)\n\t}\n\tif !reflect.DeepEqual(fullReq.Tools, defaultReq.Tools) {\n\t\tt.Fatalf(\"explicit full mode changed provider-visible tool schemas; names=%v\", toolSchemaNames(fullReq.Tools))\n\t}\n\tif requestHasTool(fullReq, \"connect_tool_source\") {\n\t\tt.Fatalf(\"full mode should not expose economy connector; tools=%v\", toolSchemaNames(fullReq.Tools))\n\t}\n}\n\nfunc TestBuildTokenEconomyStartsWithLeanToolSurface(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\", testutil.Turn{Text: \"done\"})\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n\n[[plugins]]\nname = \"mockmcp\"\ncommand = \"reasonix-missing-mockmcp\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"use the lean surface\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 1 {\n\t\tt.Fatalf(\"requests = %d, want 1\", len(reqs))\n\t}\n\treq := reqs[0]\n\twantTools := []string{\n\t\t\"ask\",\n\t\t\"bash\",\n\t\t\"bash_output\",\n\t\t\"complete_step\",\n\t\t\"connect_tool_source\",\n\t\t\"edit_file\",\n\t\t\"forget\",\n\t\t\"glob\",\n\t\t\"grep\",\n\t\t\"history\",\n\t\t\"kill_shell\",\n\t\t\"ls\",\n\t\t\"memory\",\n\t\t\"multi_edit\",\n\t\t\"read_file\",\n\t\t\"remember\",\n\t\t\"slash_command\",\n\t\t\"todo_write\",\n\t\t\"wait\",\n\t\t\"write_file\",\n\t}\n\tif got := toolSchemaNames(req.Tools); !reflect.DeepEqual(got, wantTools) {\n\t\tt.Fatalf(\"economy first request tool order changed\\ngot %v\\nwant %v\", got, wantTools)\n\t}\n\tfor _, want := range []string{\"connect_tool_source\", \"read_file\", \"grep\", \"edit_file\", \"bash\", \"slash_command\", \"ask\"} {\n\t\tif !requestHasTool(req, want) {\n\t\t\tt.Fatalf(\"economy first request missing tool %q; tools=%v\", want, toolSchemaNames(req.Tools))\n\t\t}\n\t}\n\tfor _, forbidden := range []string{\n\t\t\"web_fetch\", \"task\", \"run_skill\", \"read_skill\", \"install_skill\", \"install_source\",\n\t\t\"explore\", \"research\", \"review\", \"security_review\",\n\t\t\"lsp_definition\", \"lsp_references\", \"lsp_hover\", \"lsp_diagnostics\",\n\t} {\n\t\tif requestHasTool(req, forbidden) {\n\t\t\tt.Fatalf(\"economy first request should hide %q; tools=%v\", forbidden, toolSchemaNames(req.Tools))\n\t\t}\n\t}\n\tif requestHasToolPrefix(req, \"mcp__mockmcp\") {\n\t\tt.Fatalf(\"economy first request should not expose MCP placeholders; tools=%v\", toolSchemaNames(req.Tools))\n\t}\n\tsys := systemMessage(req.Messages)\n\tif !strings.Contains(sys, tokenEconomyPrompt) {\n\t\tt.Fatalf(\"token economy prompt missing from system message:\\n%s\", sys)\n\t}\n\tif strings.Contains(sys, \"# Skills\") || strings.Contains(sys, \"projskill\") {\n\t\tt.Fatalf(\"skills index should not be in economy system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildTokenEconomyConnectsWebFetchOnDemand(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"fetch later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasTool(reqs[0], \"web_fetch\") {\n\t\tt.Fatalf(\"first request should hide web_fetch; tools=%v\", toolSchemaNames(reqs[0].Tools))\n\t}\n\tif !requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"second request should expose web_fetch after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n}\n\nfunc TestBuildTokenEconomyPlanModeCanConnectWebFetch(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tctrl.SetPlanMode(true)\n\tif err := ctrl.Run(context.Background(), \"fetch later while planning\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif !requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"second request should expose web_fetch in plan economy mode; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" && strings.Contains(msg.Content, \"blocked:\") {\n\t\t\tt.Fatalf(\"connect_tool_source should not be blocked in plan mode, got:\\n%s\", msg.Content)\n\t\t}\n\t}\n}\n\nfunc TestBuildTokenEconomyWebFetchConnectorHonorsDisabledBuiltin(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"web_fetch\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[tools]\nenabled = [\"read_file\", \"grep\"]\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"fetch later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tif requestHasTool(reqs[1], \"web_fetch\") {\n\t\tt.Fatalf(\"disabled web_fetch should not be exposed after connect_tool_source; tools=%v\", toolSchemaNames(reqs[1].Tools))\n\t}\n\tvar toolOutput string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" {\n\t\t\ttoolOutput += msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolOutput, \"web_fetch is disabled by [tools].enabled\") {\n\t\tt.Fatalf(\"connector should explain disabled web_fetch, got:\\n%s\", toolOutput)\n\t}\n}\n\nfunc TestBuildTokenEconomyConnectsSkillsOnDemand(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootTokenProfileTestProvider()\n\tprov := testutil.NewMock(\"token-economy\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"source-1\", Name: \"connect_tool_source\", Arguments: `{\"source\":\"skills\"}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootTokenProfileTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-token-profile-test\"\nmodel = \"x\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, TokenMode: TokenModeEconomy})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tif err := ctrl.Run(context.Background(), \"use skills later\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) != 2 {\n\t\tt.Fatalf(\"requests = %d, want 2\", len(reqs))\n\t}\n\tfor _, name := range []string{\"run_skill\", \"read_skill\", \"explore\"} {\n\t\tif requestHasTool(reqs[0], name) {\n\t\t\tt.Fatalf(\"first request should hide %q; tools=%v\", name, toolSchemaNames(reqs[0].Tools))\n\t\t}\n\t\tif !requestHasTool(reqs[1], name) {\n\t\t\tt.Fatalf(\"second request should expose %q after connect_tool_source; tools=%v\", name, toolSchemaNames(reqs[1].Tools))\n\t\t}\n\t}\n\tvar toolOutput string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool && msg.Name == \"connect_tool_source\" {\n\t\t\ttoolOutput += msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolOutput, \"projskill\") || !strings.Contains(toolOutput, \"# Skills\") {\n\t\tt.Fatalf(\"skills source result should include the skill index, got:\\n%s\", toolOutput)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{})\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(workspace*)\"]\n`)\n\n\tconst rule = \"Bash(go test ./...)\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleCreatesWorkspaceConfigOverUserConfig(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tworkspace := robustTempDir(t)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user)\"]\n`)\n\n\tconst rule = \"Edit(src/app.go)\"\n\tres := rememberPermissionRule(workspace, rule)\n\tif !res.Saved || res.Path != filepath.Join(workspace, \"reasonix.toml\") {\n\t\tt.Fatalf(\"remember result = %+v, want saved to workspace config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule was written to user config: %v\", userCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule missing from project config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user*)\"]\n`)\n\n\tconst rule = \"Bash(go env)\"\n\tres := rememberPermissionRule(\"\", rule)\n\tif !res.Saved || res.Path != userConfig {\n\t\tt.Fatalf(\"remember result = %+v, want saved to user source config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc TestRememberPermissionRuleSkipsRuleCoveredByExistingAllow(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test:*)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test ./...)\")\n\tif res.Saved || res.CoveredBy != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"remember result = %+v, want already covered\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif len(cfg.Permissions.Allow) != 1 || cfg.Permissions.Allow[0] != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"allow rules = %v, want only existing prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRulePrunesNarrowRulesWhenSavingBroaderRule(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test ./...)\", \"Bash(go build ./...)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test:*)\")\n\tif !res.Saved || res.CoveredBy != \"\" {\n\t\tt.Fatalf(\"remember result = %+v, want saved broader rule\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test ./...)\") {\n\t\tt.Fatalf(\"narrow go test rule should be pruned: %v\", cfg.Permissions.Allow)\n\t}\n\tif !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go build ./...)\") || !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test:*)\") {\n\t\tt.Fatalf(\"allow rules = %v, want unrelated exact plus prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuiltInMCPsYieldToExtraPluginNames(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, []string{\"time\", \"context7\"}, pluginSpecNames([]plugin.Spec{{Name: \"time\"}})...)\n\tif len(got) != 1 || got[0].Name != \"context7\" {\n\t\tt.Fatalf(\"built-in MCP entries with extra time = %+v, want only context7\", got)\n\t}\n}\n\nfunc TestBuiltInMCPDefaultsEnableOnlyTime(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, config.Default().BuiltInMCP.EnabledNames())\n\tif len(got) != 1 || got[0].Name != \"time\" {\n\t\tt.Fatalf(\"default built-in MCP entries = %+v, want only time\", got)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildWarmCodegraphIgnoresLegacyEagerTier(t *testing.T) {\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"fake launcher is a POSIX-sh script\")\n\t}\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.Mkdir(filepath.Join(dir, \".codegraph\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tlauncher := filepath.Join(dir, \"slow-codegraph\")\n\twriteFile(t, dir, \"slow-codegraph\", \"#!/bin/sh\\nif [ \\\"$1\\\" = serve ]; then sleep 5; fi\\n\")\n\tif err := os.Chmod(launcher, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"eager\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not block on warm codegraph with legacy eager tier: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildDefaultsToNearestGitRoot(t *testing.T) {\n\tisolateConfigHome(t)\n\troot := robustTempDir(t)\n\tif err := os.Mkdir(filepath.Join(root, \".git\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsubdir := filepath.Join(root, \"cmd\", \"tool\")\n\tif err := os.MkdirAll(subdir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\twriteFile(t, root, \"reasonix.toml\", `\ndefault_model = \"root-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"root-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\tt.Chdir(subdir)\n\n\tctrl, err := Build(context.Background(), Options{Model: \"root-model\"})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should load config from nearest git root: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "92854c6a00c914bd735993484d12bc1e189fe57f": {"t": 1785750706.5412734, "n": 51354, "h": "10a09690111f68ef", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/agent/testutil\"\n\t\"reasonix/internal/builtinmcp\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/memory\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestBuildRegistersUsableHistoryAndMemoryRetrievalTools(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-retrieval-tool-test\"\nmodel = \"x\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"sessions\")\n\tif err := os.MkdirAll(sessionDir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tpast := agent.NewSession(\"\")\n\tpast.Add(provider.Message{Role: provider.RoleUser, Content: \"Should the history layer use vector embeddings?\"})\n\tpast.Add(provider.Message{Role: provider.RoleAssistant, Content: \"Decision: port lightweight BM25 history retrieval without a vector database.\"})\n\tif err := past.Save(filepath.Join(sessionDir, \"past.jsonl\")); err != nil {\n\t\tt.Fatalf(\"save past session: %v\", err)\n\t}\n\n\tstore := memory.StoreFor(config.MemoryUserDir(), dir)\n\tif _, err := store.Save(memory.Memory{\n\t\tName: \"synthesis-cache-policy\",\n\t\tDescription: \"Stable conclusions should be reused from memory\",\n\t\tType: memory.TypeFeedback,\n\t\tBody: \"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t}); err != nil {\n\t\tt.Fatalf(\"save memory: %v\", err)\n\t}\n\n\tregisterBootRetrievalToolTestProvider()\n\tprov := testutil.NewMock(\"boot-retrieval-tool-test\",\n\t\ttestutil.Turn{ToolCalls: []provider.ToolCall{\n\t\t\t{ID: \"history-1\", Name: \"history\", Arguments: `{\"operation\":\"search\",\"query\":\"BM25 vector database\",\"scope\":\"project\",\"limit\":5}`},\n\t\t\t{ID: \"memory-1\", Name: \"memory\", Arguments: `{\"operation\":\"search\",\"query\":\"synthesis cache stable conclusion\",\"limit\":5}`},\n\t\t}},\n\t\ttestutil.Turn{Text: \"done\"},\n\t)\n\tsetBootRetrievalToolTestProvider(t, prov)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard, SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tfor _, forbidden := range []string{\n\t\t\"Decision: port lightweight BM25 history retrieval without a vector database.\",\n\t\t\"Use a synthesis cache document when expensive retrieval produced a stable conclusion.\",\n\t} {\n\t\tif strings.Contains(sys, forbidden) {\n\t\t\tt.Fatalf(\"retrieval content should stay behind on-demand tools, not enter the cache-stable system prompt:\\n%s\", sys)\n\t\t}\n\t}\n\n\tif err := ctrl.Run(context.Background(), \"recover past context\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\treqs := prov.Requests()\n\tif len(reqs) == 0 {\n\t\tt.Fatal(\"provider received no requests\")\n\t}\n\tfor _, want := range []string{\"history\", \"memory\", \"remember\", \"forget\"} {\n\t\tif !requestHasTool(reqs[0], want) {\n\t\t\tt.Fatalf(\"first request missing tool %q; tools=%v\", want, toolSchemaNames(reqs[0].Tools))\n\t\t}\n\t}\n\tassertToolOrder(t, reqs[0].Tools, []string{\"forget\", \"history\", \"memory\", \"remember\"})\n\n\ttoolResults := map[string]string{}\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolResults[msg.Name] += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif !strings.Contains(toolResults[\"history\"], \"port lightweight BM25 history retrieval\") {\n\t\tt.Fatalf(\"history tool result did not include saved session decision:\\n%s\", toolResults[\"history\"])\n\t}\n\tif !strings.Contains(toolResults[\"memory\"], \"synthesis-cache-policy\") ||\n\t\t!strings.Contains(toolResults[\"memory\"], \"stable conclusion\") {\n\t\tt.Fatalf(\"memory tool result did not include saved memory:\\n%s\", toolResults[\"memory\"])\n\t}\n}\n\nconst bootRetrievalToolTestProviderKind = \"boot-retrieval-tool-test\"\n\nvar (\n\tbootRetrievalToolTestProviderOnce sync.Once\n\tbootRetrievalToolTestProviderCurrent *testutil.MockProvider\n\tbootRetrievalToolTestProviderMu sync.Mutex\n)\n\nfunc registerBootRetrievalToolTestProvider() {\n\tbootRetrievalToolTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootRetrievalToolTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\t\tdefer bootRetrievalToolTestProviderMu.Unlock()\n\t\t\tif bootRetrievalToolTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot retrieval tool test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootRetrievalToolTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootRetrievalToolTestProvider(t *testing.T, p *testutil.MockProvider) {\n\tt.Helper()\n\tbootRetrievalToolTestProviderMu.Lock()\n\tbootRetrievalToolTestProviderCurrent = p\n\tbootRetrievalToolTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootRetrievalToolTestProviderMu.Lock()\n\t\tif bootRetrievalToolTestProviderCurrent == p {\n\t\t\tbootRetrievalToolTestProviderCurrent = nil\n\t\t}\n\t\tbootRetrievalToolTestProviderMu.Unlock()\n\t})\n}\n\nfunc requestHasTool(req provider.Request, name string) bool {\n\tfor _, schema := range req.Tools {\n\t\tif schema.Name == name {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc toolSchemaNames(tools []provider.ToolSchema) []string {\n\tnames := make([]string, 0, len(tools))\n\tfor _, schema := range tools {\n\t\tnames = append(names, schema.Name)\n\t}\n\treturn names\n}\n\nfunc assertToolOrder(t *testing.T, tools []provider.ToolSchema, want []string) {\n\tt.Helper()\n\tnames := toolSchemaNames(tools)\n\tnext := 0\n\tfor _, name := range names {\n\t\tif next < len(want) && name == want[next] {\n\t\t\tnext++\n\t\t}\n\t}\n\tif next != len(want) {\n\t\tt.Fatalf(\"tool order changed; provider-visible tool schema order affects prompt-cache shape.\\nwant subsequence: %v\\n got: %v\", want, names)\n\t}\n}\n\nfunc TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"first Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\tprov.setContinueRef(ref)\n\n\tif err := ctrl.Run(context.Background(), \"continue review\"); err != nil {\n\t\tt.Fatalf(\"second Run: %v\", err)\n\t}\n\tstore := agent.NewSubagentStore(filepath.Join(config.SessionDir(), \"subagents\"))\n\tmeta, err := store.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta: %v\", err)\n\t}\n\tif meta.Status != agent.SubagentFailed {\n\t\tt.Fatalf(\"status = %q, want failed\", meta.Status)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tsess, err := agent.LoadSession(filepath.Join(config.SessionDir(), \"subagents\", ref+\".jsonl\"))\n\tif err != nil {\n\t\tt.Fatalf(\"LoadSession: %v\", err)\n\t}\n\tmsgs := sess.Snapshot()\n\tif len(msgs) != 4 || msgs[1].Content != \"first skill task\" || msgs[2].Content != \"first skill answer\" || msgs[3].Content != \"second skill task\" {\n\t\tt.Fatalf(\"failed skill transcript = %+v, want first task/answer plus second task\", msgs)\n\t}\n}\n\nconst bootSubagentTestProviderKind = \"boot-subagent-test\"\n\nvar (\n\tbootSubagentTestProviderOnce sync.Once\n\tbootSubagentTestProviderCurrent *bootSubagentTestProvider\n\tbootSubagentTestProviderMu sync.Mutex\n)\n\nfunc registerBootSubagentTestProvider() {\n\tbootSubagentTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootSubagentTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootSubagentTestProviderMu.Lock()\n\t\t\tdefer bootSubagentTestProviderMu.Unlock()\n\t\t\tif bootSubagentTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot subagent test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootSubagentTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootSubagentTestProvider(t *testing.T, p *bootSubagentTestProvider) {\n\tt.Helper()\n\tbootSubagentTestProviderMu.Lock()\n\tbootSubagentTestProviderCurrent = p\n\tbootSubagentTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootSubagentTestProviderMu.Lock()\n\t\tif bootSubagentTestProviderCurrent == p {\n\t\t\tbootSubagentTestProviderCurrent = nil\n\t\t}\n\t\tbootSubagentTestProviderMu.Unlock()\n\t})\n}\n\ntype bootSubagentTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n\tcontinueRef string\n}\n\nfunc (p *bootSubagentTestProvider) Name() string { return \"boot-subagent-test\" }\n\nfunc (p *bootSubagentTestProvider) setContinueRef(ref string) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tp.continueRef = ref\n}\n\nfunc (p *bootSubagentTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tref := p.continueRef\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-1\", Name: \"review\", Arguments: `{\"task\":\"first skill task\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"first skill answer\"}, {Type: provider.ChunkDone}}\n\tcase 2:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent first done\"}, {Type: provider.ChunkDone}}\n\tcase 3:\n\t\targs, _ := json.Marshal(map[string]string{\"task\": \"second skill task\", \"continue_from\": ref})\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-2\", Name: \"review\", Arguments: string(args)}}}\n\tcase 4:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: errors.New(\"subagent skill failed\")}}\n\tcase 5:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent second done\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf(\"unexpected provider call %d\", call)}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc subagentRefFromHistory(t *testing.T, msgs []provider.Message) string {\n\tt.Helper()\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, line := range strings.Split(msg.Content, \"\\n\") {\n\t\t\tif strings.HasPrefix(line, \"Subagent reference: \") {\n\t\t\t\treturn strings.TrimSpace(strings.TrimPrefix(line, \"Subagent reference: \"))\n\t\t\t}\n\t\t}\n\t}\n\tt.Fatalf(\"no subagent reference in history: %+v\", msgs)\n\treturn \"\"\n}\n\n// TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath reproduces headless\n// `reasonix run`: a controller built via Build with NO SetSessionPath (exactly\n// what internal/cli.runAgent does) must still be able to run a `task` sub-agent.\n// Before the ephemeral fallback this failed with \"parent session is required\".\nfunc TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterHeadlessTaskTestProvider()\n\tprov := &headlessTaskTestProvider{}\n\tsetHeadlessTaskTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-headless-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// Deliberately NOT calling SetSessionPath — this is the headless run path.\n\tif err := ctrl.Run(context.Background(), \"use a task subagent\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tif got := ctrl.SessionPath(); got != \"\" {\n\t\tt.Fatalf(\"headless run should keep an empty session path, got %q\", got)\n\t}\n\n\tvar toolContent string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolContent += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif strings.Contains(toolContent, \"parent session is required\") {\n\t\tt.Fatalf(\"task subagent failed in headless run mode: %s\", toolContent)\n\t}\n\tif !strings.Contains(toolContent, \"subagent answer\") {\n\t\tt.Fatalf(\"task tool result = %q, want sub-agent answer\", toolContent)\n\t}\n\tif strings.Contains(toolContent, \"Subagent reference\") {\n\t\tt.Fatalf(\"ephemeral headless run should not persist a transcript reference: %s\", toolContent)\n\t}\n}\n\nconst headlessTaskTestProviderKind = \"boot-headless-test\"\n\nvar (\n\theadlessTaskTestProviderOnce sync.Once\n\theadlessTaskTestProviderCurrent *headlessTaskTestProvider\n\theadlessTaskTestProviderMu sync.Mutex\n)\n\nfunc registerHeadlessTaskTestProvider() {\n\theadlessTaskTestProviderOnce.Do(func() {\n\t\tprovider.Register(headlessTaskTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\theadlessTaskTestProviderMu.Lock()\n\t\t\tdefer headlessTaskTestProviderMu.Unlock()\n\t\t\tif headlessTaskTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"headless task test provider is not installed\")\n\t\t\t}\n\t\t\treturn headlessTaskTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setHeadlessTaskTestProvider(t *testing.T, p *headlessTaskTestProvider) {\n\tt.Helper()\n\theadlessTaskTestProviderMu.Lock()\n\theadlessTaskTestProviderCurrent = p\n\theadlessTaskTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\theadlessTaskTestProviderMu.Lock()\n\t\tif headlessTaskTestProviderCurrent == p {\n\t\t\theadlessTaskTestProviderCurrent = nil\n\t\t}\n\t\theadlessTaskTestProviderMu.Unlock()\n\t})\n}\n\ntype headlessTaskTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n}\n\nfunc (p *headlessTaskTestProvider) Name() string { return \"boot-headless-test\" }\n\nfunc (p *headlessTaskTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"task-1\", Name: \"task\", Arguments: `{\"prompt\":\"find callers\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"subagent answer\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent done\"}, {Type: provider.ChunkDone}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\nfunc TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4-flash\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want high from DeepSeek model capability\", got)\n\t}\n\tthinking, ok := gotReq[\"thinking\"].(map[string]any)\n\tif !ok || thinking[\"type\"] != \"enabled\" {\n\t\tt.Fatalf(\"thinking = %#v, want enabled\", gotReq[\"thinking\"])\n\t}\n}\n\nfunc TestBuildHonorsSessionDirOverride(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.SessionDir(); got != sessionDir {\n\t\tt.Fatalf(\"SessionDir() = %q, want override %q\", got, sessionDir)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{})\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(workspace*)\"]\n`)\n\n\tconst rule = \"Bash(go test ./...)\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleCreatesWorkspaceConfigOverUserConfig(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tworkspace := robustTempDir(t)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user)\"]\n`)\n\n\tconst rule = \"Edit(src/app.go)\"\n\tres := rememberPermissionRule(workspace, rule)\n\tif !res.Saved || res.Path != filepath.Join(workspace, \"reasonix.toml\") {\n\t\tt.Fatalf(\"remember result = %+v, want saved to workspace config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule was written to user config: %v\", userCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule missing from project config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user*)\"]\n`)\n\n\tconst rule = \"Bash(go env)\"\n\tres := rememberPermissionRule(\"\", rule)\n\tif !res.Saved || res.Path != userConfig {\n\t\tt.Fatalf(\"remember result = %+v, want saved to user source config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc TestRememberPermissionRuleSkipsRuleCoveredByExistingAllow(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test:*)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test ./...)\")\n\tif res.Saved || res.CoveredBy != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"remember result = %+v, want already covered\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif len(cfg.Permissions.Allow) != 1 || cfg.Permissions.Allow[0] != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"allow rules = %v, want only existing prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRulePrunesNarrowRulesWhenSavingBroaderRule(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test ./...)\", \"Bash(go build ./...)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test:*)\")\n\tif !res.Saved || res.CoveredBy != \"\" {\n\t\tt.Fatalf(\"remember result = %+v, want saved broader rule\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test ./...)\") {\n\t\tt.Fatalf(\"narrow go test rule should be pruned: %v\", cfg.Permissions.Allow)\n\t}\n\tif !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go build ./...)\") || !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test:*)\") {\n\t\tt.Fatalf(\"allow rules = %v, want unrelated exact plus prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuiltInMCPsYieldToExtraPluginNames(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, []string{\"time\", \"context7\"}, pluginSpecNames([]plugin.Spec{{Name: \"time\"}})...)\n\tif len(got) != 1 || got[0].Name != \"context7\" {\n\t\tt.Fatalf(\"built-in MCP entries with extra time = %+v, want only context7\", got)\n\t}\n}\n\nfunc TestBuiltInMCPDefaultsEnableOnlyTime(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, config.Default().BuiltInMCP.EnabledNames())\n\tif len(got) != 1 || got[0].Name != \"time\" {\n\t\tt.Fatalf(\"default built-in MCP entries = %+v, want only time\", got)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildWarmCodegraphIgnoresLegacyEagerTier(t *testing.T) {\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"fake launcher is a POSIX-sh script\")\n\t}\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.Mkdir(filepath.Join(dir, \".codegraph\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tlauncher := filepath.Join(dir, \"slow-codegraph\")\n\twriteFile(t, dir, \"slow-codegraph\", \"#!/bin/sh\\nif [ \\\"$1\\\" = serve ]; then sleep 5; fi\\n\")\n\tif err := os.Chmod(launcher, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"eager\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not block on warm codegraph with legacy eager tier: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildDefaultsToNearestGitRoot(t *testing.T) {\n\tisolateConfigHome(t)\n\troot := robustTempDir(t)\n\tif err := os.Mkdir(filepath.Join(root, \".git\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsubdir := filepath.Join(root, \"cmd\", \"tool\")\n\tif err := os.MkdirAll(subdir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\twriteFile(t, root, \"reasonix.toml\", `\ndefault_model = \"root-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"root-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\tt.Chdir(subdir)\n\n\tctrl, err := Build(context.Background(), Options{Model: \"root-model\"})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should load config from nearest git root: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "d05f8075ef184aa43a5fd807c38630c275d8558d": {"t": 1785750707.8419697, "n": 45909, "h": "b576636df7eac702", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/builtinmcp\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"first Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\tprov.setContinueRef(ref)\n\n\tif err := ctrl.Run(context.Background(), \"continue review\"); err != nil {\n\t\tt.Fatalf(\"second Run: %v\", err)\n\t}\n\tstore := agent.NewSubagentStore(filepath.Join(config.SessionDir(), \"subagents\"))\n\tmeta, err := store.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta: %v\", err)\n\t}\n\tif meta.Status != agent.SubagentFailed {\n\t\tt.Fatalf(\"status = %q, want failed\", meta.Status)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tsess, err := agent.LoadSession(filepath.Join(config.SessionDir(), \"subagents\", ref+\".jsonl\"))\n\tif err != nil {\n\t\tt.Fatalf(\"LoadSession: %v\", err)\n\t}\n\tmsgs := sess.Snapshot()\n\tif len(msgs) != 4 || msgs[1].Content != \"first skill task\" || msgs[2].Content != \"first skill answer\" || msgs[3].Content != \"second skill task\" {\n\t\tt.Fatalf(\"failed skill transcript = %+v, want first task/answer plus second task\", msgs)\n\t}\n}\n\nconst bootSubagentTestProviderKind = \"boot-subagent-test\"\n\nvar (\n\tbootSubagentTestProviderOnce sync.Once\n\tbootSubagentTestProviderCurrent *bootSubagentTestProvider\n\tbootSubagentTestProviderMu sync.Mutex\n)\n\nfunc registerBootSubagentTestProvider() {\n\tbootSubagentTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootSubagentTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootSubagentTestProviderMu.Lock()\n\t\t\tdefer bootSubagentTestProviderMu.Unlock()\n\t\t\tif bootSubagentTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot subagent test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootSubagentTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootSubagentTestProvider(t *testing.T, p *bootSubagentTestProvider) {\n\tt.Helper()\n\tbootSubagentTestProviderMu.Lock()\n\tbootSubagentTestProviderCurrent = p\n\tbootSubagentTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootSubagentTestProviderMu.Lock()\n\t\tif bootSubagentTestProviderCurrent == p {\n\t\t\tbootSubagentTestProviderCurrent = nil\n\t\t}\n\t\tbootSubagentTestProviderMu.Unlock()\n\t})\n}\n\ntype bootSubagentTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n\tcontinueRef string\n}\n\nfunc (p *bootSubagentTestProvider) Name() string { return \"boot-subagent-test\" }\n\nfunc (p *bootSubagentTestProvider) setContinueRef(ref string) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tp.continueRef = ref\n}\n\nfunc (p *bootSubagentTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tref := p.continueRef\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-1\", Name: \"review\", Arguments: `{\"task\":\"first skill task\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"first skill answer\"}, {Type: provider.ChunkDone}}\n\tcase 2:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent first done\"}, {Type: provider.ChunkDone}}\n\tcase 3:\n\t\targs, _ := json.Marshal(map[string]string{\"task\": \"second skill task\", \"continue_from\": ref})\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-2\", Name: \"review\", Arguments: string(args)}}}\n\tcase 4:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: errors.New(\"subagent skill failed\")}}\n\tcase 5:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent second done\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf(\"unexpected provider call %d\", call)}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc subagentRefFromHistory(t *testing.T, msgs []provider.Message) string {\n\tt.Helper()\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, line := range strings.Split(msg.Content, \"\\n\") {\n\t\t\tif strings.HasPrefix(line, \"Subagent reference: \") {\n\t\t\t\treturn strings.TrimSpace(strings.TrimPrefix(line, \"Subagent reference: \"))\n\t\t\t}\n\t\t}\n\t}\n\tt.Fatalf(\"no subagent reference in history: %+v\", msgs)\n\treturn \"\"\n}\n\n// TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath reproduces headless\n// `reasonix run`: a controller built via Build with NO SetSessionPath (exactly\n// what internal/cli.runAgent does) must still be able to run a `task` sub-agent.\n// Before the ephemeral fallback this failed with \"parent session is required\".\nfunc TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterHeadlessTaskTestProvider()\n\tprov := &headlessTaskTestProvider{}\n\tsetHeadlessTaskTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-headless-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// Deliberately NOT calling SetSessionPath — this is the headless run path.\n\tif err := ctrl.Run(context.Background(), \"use a task subagent\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tif got := ctrl.SessionPath(); got != \"\" {\n\t\tt.Fatalf(\"headless run should keep an empty session path, got %q\", got)\n\t}\n\n\tvar toolContent string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolContent += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif strings.Contains(toolContent, \"parent session is required\") {\n\t\tt.Fatalf(\"task subagent failed in headless run mode: %s\", toolContent)\n\t}\n\tif !strings.Contains(toolContent, \"subagent answer\") {\n\t\tt.Fatalf(\"task tool result = %q, want sub-agent answer\", toolContent)\n\t}\n\tif strings.Contains(toolContent, \"Subagent reference\") {\n\t\tt.Fatalf(\"ephemeral headless run should not persist a transcript reference: %s\", toolContent)\n\t}\n}\n\nconst headlessTaskTestProviderKind = \"boot-headless-test\"\n\nvar (\n\theadlessTaskTestProviderOnce sync.Once\n\theadlessTaskTestProviderCurrent *headlessTaskTestProvider\n\theadlessTaskTestProviderMu sync.Mutex\n)\n\nfunc registerHeadlessTaskTestProvider() {\n\theadlessTaskTestProviderOnce.Do(func() {\n\t\tprovider.Register(headlessTaskTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\theadlessTaskTestProviderMu.Lock()\n\t\t\tdefer headlessTaskTestProviderMu.Unlock()\n\t\t\tif headlessTaskTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"headless task test provider is not installed\")\n\t\t\t}\n\t\t\treturn headlessTaskTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setHeadlessTaskTestProvider(t *testing.T, p *headlessTaskTestProvider) {\n\tt.Helper()\n\theadlessTaskTestProviderMu.Lock()\n\theadlessTaskTestProviderCurrent = p\n\theadlessTaskTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\theadlessTaskTestProviderMu.Lock()\n\t\tif headlessTaskTestProviderCurrent == p {\n\t\t\theadlessTaskTestProviderCurrent = nil\n\t\t}\n\t\theadlessTaskTestProviderMu.Unlock()\n\t})\n}\n\ntype headlessTaskTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n}\n\nfunc (p *headlessTaskTestProvider) Name() string { return \"boot-headless-test\" }\n\nfunc (p *headlessTaskTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"task-1\", Name: \"task\", Arguments: `{\"prompt\":\"find callers\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"subagent answer\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent done\"}, {Type: provider.ChunkDone}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\nfunc TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4-flash\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want high from DeepSeek model capability\", got)\n\t}\n\tthinking, ok := gotReq[\"thinking\"].(map[string]any)\n\tif !ok || thinking[\"type\"] != \"enabled\" {\n\t\tt.Fatalf(\"thinking = %#v, want enabled\", gotReq[\"thinking\"])\n\t}\n}\n\nfunc TestBuildHonorsSessionDirOverride(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.SessionDir(); got != sessionDir {\n\t\tt.Fatalf(\"SessionDir() = %q, want override %q\", got, sessionDir)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{})\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(workspace*)\"]\n`)\n\n\tconst rule = \"Bash(go test ./...)\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleCreatesWorkspaceConfigOverUserConfig(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tworkspace := robustTempDir(t)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user)\"]\n`)\n\n\tconst rule = \"Edit(src/app.go)\"\n\tres := rememberPermissionRule(workspace, rule)\n\tif !res.Saved || res.Path != filepath.Join(workspace, \"reasonix.toml\") {\n\t\tt.Fatalf(\"remember result = %+v, want saved to workspace config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule was written to user config: %v\", userCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule missing from project config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user*)\"]\n`)\n\n\tconst rule = \"Bash(go env)\"\n\tres := rememberPermissionRule(\"\", rule)\n\tif !res.Saved || res.Path != userConfig {\n\t\tt.Fatalf(\"remember result = %+v, want saved to user source config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc TestRememberPermissionRuleSkipsRuleCoveredByExistingAllow(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test:*)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test ./...)\")\n\tif res.Saved || res.CoveredBy != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"remember result = %+v, want already covered\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif len(cfg.Permissions.Allow) != 1 || cfg.Permissions.Allow[0] != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"allow rules = %v, want only existing prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRulePrunesNarrowRulesWhenSavingBroaderRule(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test ./...)\", \"Bash(go build ./...)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test:*)\")\n\tif !res.Saved || res.CoveredBy != \"\" {\n\t\tt.Fatalf(\"remember result = %+v, want saved broader rule\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test ./...)\") {\n\t\tt.Fatalf(\"narrow go test rule should be pruned: %v\", cfg.Permissions.Allow)\n\t}\n\tif !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go build ./...)\") || !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test:*)\") {\n\t\tt.Fatalf(\"allow rules = %v, want unrelated exact plus prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuiltInMCPsYieldToExtraPluginNames(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, []string{\"time\", \"context7\"}, pluginSpecNames([]plugin.Spec{{Name: \"time\"}})...)\n\tif len(got) != 1 || got[0].Name != \"context7\" {\n\t\tt.Fatalf(\"built-in MCP entries with extra time = %+v, want only context7\", got)\n\t}\n}\n\nfunc TestBuiltInMCPDefaultsEnableOnlyTime(t *testing.T) {\n\tgot := builtinmcp.AppendEnabled(nil, nil, config.Default().BuiltInMCP.EnabledNames())\n\tif len(got) != 1 || got[0].Name != \"time\" {\n\t\tt.Fatalf(\"default built-in MCP entries = %+v, want only time\", got)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildWarmCodegraphIgnoresLegacyEagerTier(t *testing.T) {\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"fake launcher is a POSIX-sh script\")\n\t}\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.Mkdir(filepath.Join(dir, \".codegraph\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tlauncher := filepath.Join(dir, \"slow-codegraph\")\n\twriteFile(t, dir, \"slow-codegraph\", \"#!/bin/sh\\nif [ \\\"$1\\\" = serve ]; then sleep 5; fi\\n\")\n\tif err := os.Chmod(launcher, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"eager\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not block on warm codegraph with legacy eager tier: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildDefaultsToNearestGitRoot(t *testing.T) {\n\tisolateConfigHome(t)\n\troot := robustTempDir(t)\n\tif err := os.Mkdir(filepath.Join(root, \".git\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsubdir := filepath.Join(root, \"cmd\", \"tool\")\n\tif err := os.MkdirAll(subdir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\twriteFile(t, root, \"reasonix.toml\", `\ndefault_model = \"root-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"root-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\tt.Chdir(subdir)\n\n\tctrl, err := Build(context.Background(), Options{Model: \"root-model\"})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should load config from nearest git root: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "54531f34a21c7ecdb1e649bbc45493cc4813612d": {"t": 1785750709.2902265, "n": 45290, "h": "68429516086b3ff9", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"first Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\tprov.setContinueRef(ref)\n\n\tif err := ctrl.Run(context.Background(), \"continue review\"); err != nil {\n\t\tt.Fatalf(\"second Run: %v\", err)\n\t}\n\tstore := agent.NewSubagentStore(filepath.Join(config.SessionDir(), \"subagents\"))\n\tmeta, err := store.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta: %v\", err)\n\t}\n\tif meta.Status != agent.SubagentFailed {\n\t\tt.Fatalf(\"status = %q, want failed\", meta.Status)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tsess, err := agent.LoadSession(filepath.Join(config.SessionDir(), \"subagents\", ref+\".jsonl\"))\n\tif err != nil {\n\t\tt.Fatalf(\"LoadSession: %v\", err)\n\t}\n\tmsgs := sess.Snapshot()\n\tif len(msgs) != 4 || msgs[1].Content != \"first skill task\" || msgs[2].Content != \"first skill answer\" || msgs[3].Content != \"second skill task\" {\n\t\tt.Fatalf(\"failed skill transcript = %+v, want first task/answer plus second task\", msgs)\n\t}\n}\n\nconst bootSubagentTestProviderKind = \"boot-subagent-test\"\n\nvar (\n\tbootSubagentTestProviderOnce sync.Once\n\tbootSubagentTestProviderCurrent *bootSubagentTestProvider\n\tbootSubagentTestProviderMu sync.Mutex\n)\n\nfunc registerBootSubagentTestProvider() {\n\tbootSubagentTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootSubagentTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootSubagentTestProviderMu.Lock()\n\t\t\tdefer bootSubagentTestProviderMu.Unlock()\n\t\t\tif bootSubagentTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot subagent test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootSubagentTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootSubagentTestProvider(t *testing.T, p *bootSubagentTestProvider) {\n\tt.Helper()\n\tbootSubagentTestProviderMu.Lock()\n\tbootSubagentTestProviderCurrent = p\n\tbootSubagentTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootSubagentTestProviderMu.Lock()\n\t\tif bootSubagentTestProviderCurrent == p {\n\t\t\tbootSubagentTestProviderCurrent = nil\n\t\t}\n\t\tbootSubagentTestProviderMu.Unlock()\n\t})\n}\n\ntype bootSubagentTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n\tcontinueRef string\n}\n\nfunc (p *bootSubagentTestProvider) Name() string { return \"boot-subagent-test\" }\n\nfunc (p *bootSubagentTestProvider) setContinueRef(ref string) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tp.continueRef = ref\n}\n\nfunc (p *bootSubagentTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tref := p.continueRef\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-1\", Name: \"review\", Arguments: `{\"task\":\"first skill task\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"first skill answer\"}, {Type: provider.ChunkDone}}\n\tcase 2:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent first done\"}, {Type: provider.ChunkDone}}\n\tcase 3:\n\t\targs, _ := json.Marshal(map[string]string{\"task\": \"second skill task\", \"continue_from\": ref})\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-2\", Name: \"review\", Arguments: string(args)}}}\n\tcase 4:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: errors.New(\"subagent skill failed\")}}\n\tcase 5:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent second done\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf(\"unexpected provider call %d\", call)}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc subagentRefFromHistory(t *testing.T, msgs []provider.Message) string {\n\tt.Helper()\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, line := range strings.Split(msg.Content, \"\\n\") {\n\t\t\tif strings.HasPrefix(line, \"Subagent reference: \") {\n\t\t\t\treturn strings.TrimSpace(strings.TrimPrefix(line, \"Subagent reference: \"))\n\t\t\t}\n\t\t}\n\t}\n\tt.Fatalf(\"no subagent reference in history: %+v\", msgs)\n\treturn \"\"\n}\n\n// TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath reproduces headless\n// `reasonix run`: a controller built via Build with NO SetSessionPath (exactly\n// what internal/cli.runAgent does) must still be able to run a `task` sub-agent.\n// Before the ephemeral fallback this failed with \"parent session is required\".\nfunc TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterHeadlessTaskTestProvider()\n\tprov := &headlessTaskTestProvider{}\n\tsetHeadlessTaskTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-headless-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// Deliberately NOT calling SetSessionPath — this is the headless run path.\n\tif err := ctrl.Run(context.Background(), \"use a task subagent\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tif got := ctrl.SessionPath(); got != \"\" {\n\t\tt.Fatalf(\"headless run should keep an empty session path, got %q\", got)\n\t}\n\n\tvar toolContent string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolContent += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif strings.Contains(toolContent, \"parent session is required\") {\n\t\tt.Fatalf(\"task subagent failed in headless run mode: %s\", toolContent)\n\t}\n\tif !strings.Contains(toolContent, \"subagent answer\") {\n\t\tt.Fatalf(\"task tool result = %q, want sub-agent answer\", toolContent)\n\t}\n\tif strings.Contains(toolContent, \"Subagent reference\") {\n\t\tt.Fatalf(\"ephemeral headless run should not persist a transcript reference: %s\", toolContent)\n\t}\n}\n\nconst headlessTaskTestProviderKind = \"boot-headless-test\"\n\nvar (\n\theadlessTaskTestProviderOnce sync.Once\n\theadlessTaskTestProviderCurrent *headlessTaskTestProvider\n\theadlessTaskTestProviderMu sync.Mutex\n)\n\nfunc registerHeadlessTaskTestProvider() {\n\theadlessTaskTestProviderOnce.Do(func() {\n\t\tprovider.Register(headlessTaskTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\theadlessTaskTestProviderMu.Lock()\n\t\t\tdefer headlessTaskTestProviderMu.Unlock()\n\t\t\tif headlessTaskTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"headless task test provider is not installed\")\n\t\t\t}\n\t\t\treturn headlessTaskTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setHeadlessTaskTestProvider(t *testing.T, p *headlessTaskTestProvider) {\n\tt.Helper()\n\theadlessTaskTestProviderMu.Lock()\n\theadlessTaskTestProviderCurrent = p\n\theadlessTaskTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\theadlessTaskTestProviderMu.Lock()\n\t\tif headlessTaskTestProviderCurrent == p {\n\t\t\theadlessTaskTestProviderCurrent = nil\n\t\t}\n\t\theadlessTaskTestProviderMu.Unlock()\n\t})\n}\n\ntype headlessTaskTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n}\n\nfunc (p *headlessTaskTestProvider) Name() string { return \"boot-headless-test\" }\n\nfunc (p *headlessTaskTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"task-1\", Name: \"task\", Arguments: `{\"prompt\":\"find callers\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"subagent answer\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent done\"}, {Type: provider.ChunkDone}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\nfunc TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4-flash\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want high from DeepSeek model capability\", got)\n\t}\n\tthinking, ok := gotReq[\"thinking\"].(map[string]any)\n\tif !ok || thinking[\"type\"] != \"enabled\" {\n\t\tt.Fatalf(\"thinking = %#v, want enabled\", gotReq[\"thinking\"])\n\t}\n}\n\nfunc TestBuildHonorsSessionDirOverride(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tsessionDir := filepath.Join(t.TempDir(), \"desktop-workspace-sessions\")\n\tctrl, err := Build(context.Background(), Options{SessionDir: sessionDir})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.SessionDir(); got != sessionDir {\n\t\tt.Fatalf(\"SessionDir() = %q, want override %q\", got, sessionDir)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{})\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(workspace*)\"]\n`)\n\n\tconst rule = \"Bash(go test ./...)\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleCreatesWorkspaceConfigOverUserConfig(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tworkspace := robustTempDir(t)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user)\"]\n`)\n\n\tconst rule = \"Edit(src/app.go)\"\n\tres := rememberPermissionRule(workspace, rule)\n\tif !res.Saved || res.Path != filepath.Join(workspace, \"reasonix.toml\") {\n\t\tt.Fatalf(\"remember result = %+v, want saved to workspace config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule was written to user config: %v\", userCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule missing from project config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user*)\"]\n`)\n\n\tconst rule = \"Bash(go env)\"\n\tres := rememberPermissionRule(\"\", rule)\n\tif !res.Saved || res.Path != userConfig {\n\t\tt.Fatalf(\"remember result = %+v, want saved to user source config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc TestRememberPermissionRuleSkipsRuleCoveredByExistingAllow(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test:*)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test ./...)\")\n\tif res.Saved || res.CoveredBy != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"remember result = %+v, want already covered\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif len(cfg.Permissions.Allow) != 1 || cfg.Permissions.Allow[0] != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"allow rules = %v, want only existing prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRulePrunesNarrowRulesWhenSavingBroaderRule(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test ./...)\", \"Bash(go build ./...)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test:*)\")\n\tif !res.Saved || res.CoveredBy != \"\" {\n\t\tt.Fatalf(\"remember result = %+v, want saved broader rule\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test ./...)\") {\n\t\tt.Fatalf(\"narrow go test rule should be pruned: %v\", cfg.Permissions.Allow)\n\t}\n\tif !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go build ./...)\") || !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test:*)\") {\n\t\tt.Fatalf(\"allow rules = %v, want unrelated exact plus prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildWarmCodegraphIgnoresLegacyEagerTier(t *testing.T) {\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"fake launcher is a POSIX-sh script\")\n\t}\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.Mkdir(filepath.Join(dir, \".codegraph\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tlauncher := filepath.Join(dir, \"slow-codegraph\")\n\twriteFile(t, dir, \"slow-codegraph\", \"#!/bin/sh\\nif [ \\\"$1\\\" = serve ]; then sleep 5; fi\\n\")\n\tif err := os.Chmod(launcher, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"eager\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not block on warm codegraph with legacy eager tier: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildDefaultsToNearestGitRoot(t *testing.T) {\n\tisolateConfigHome(t)\n\troot := robustTempDir(t)\n\tif err := os.Mkdir(filepath.Join(root, \".git\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsubdir := filepath.Join(root, \"cmd\", \"tool\")\n\tif err := os.MkdirAll(subdir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\twriteFile(t, root, \"reasonix.toml\", `\ndefault_model = \"root-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"root-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\tt.Chdir(subdir)\n\n\tctrl, err := Build(context.Background(), Options{Model: \"root-model\"})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should load config from nearest git root: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "5939500d0b5b842872ce4f5f48138a85c47254f5": {"t": 1785750710.6034036, "n": 44431, "h": "393190f6a1944017", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestBuildSubagentSkillFailedContinuationPersistsTranscript(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterBootSubagentTestProvider()\n\tprov := &bootSubagentTestProvider{}\n\tsetBootSubagentTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-subagent-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tsessionPath := agent.NewSessionPath(ctrl.SessionDir(), ctrl.Label())\n\tctrl.SetSessionPath(sessionPath)\n\n\tif err := ctrl.Run(context.Background(), \"first review\"); err != nil {\n\t\tt.Fatalf(\"first Run: %v\", err)\n\t}\n\tref := subagentRefFromHistory(t, ctrl.History())\n\tprov.setContinueRef(ref)\n\n\tif err := ctrl.Run(context.Background(), \"continue review\"); err != nil {\n\t\tt.Fatalf(\"second Run: %v\", err)\n\t}\n\tstore := agent.NewSubagentStore(filepath.Join(config.SessionDir(), \"subagents\"))\n\tmeta, err := store.LoadMeta(ref)\n\tif err != nil {\n\t\tt.Fatalf(\"LoadMeta: %v\", err)\n\t}\n\tif meta.Status != agent.SubagentFailed {\n\t\tt.Fatalf(\"status = %q, want failed\", meta.Status)\n\t}\n\tif meta.ParentSession != agent.BranchID(sessionPath) {\n\t\tt.Fatalf(\"parent session = %q, want %q\", meta.ParentSession, agent.BranchID(sessionPath))\n\t}\n\tsess, err := agent.LoadSession(filepath.Join(config.SessionDir(), \"subagents\", ref+\".jsonl\"))\n\tif err != nil {\n\t\tt.Fatalf(\"LoadSession: %v\", err)\n\t}\n\tmsgs := sess.Snapshot()\n\tif len(msgs) != 4 || msgs[1].Content != \"first skill task\" || msgs[2].Content != \"first skill answer\" || msgs[3].Content != \"second skill task\" {\n\t\tt.Fatalf(\"failed skill transcript = %+v, want first task/answer plus second task\", msgs)\n\t}\n}\n\nconst bootSubagentTestProviderKind = \"boot-subagent-test\"\n\nvar (\n\tbootSubagentTestProviderOnce sync.Once\n\tbootSubagentTestProviderCurrent *bootSubagentTestProvider\n\tbootSubagentTestProviderMu sync.Mutex\n)\n\nfunc registerBootSubagentTestProvider() {\n\tbootSubagentTestProviderOnce.Do(func() {\n\t\tprovider.Register(bootSubagentTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\tbootSubagentTestProviderMu.Lock()\n\t\t\tdefer bootSubagentTestProviderMu.Unlock()\n\t\t\tif bootSubagentTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"boot subagent test provider is not installed\")\n\t\t\t}\n\t\t\treturn bootSubagentTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setBootSubagentTestProvider(t *testing.T, p *bootSubagentTestProvider) {\n\tt.Helper()\n\tbootSubagentTestProviderMu.Lock()\n\tbootSubagentTestProviderCurrent = p\n\tbootSubagentTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\tbootSubagentTestProviderMu.Lock()\n\t\tif bootSubagentTestProviderCurrent == p {\n\t\t\tbootSubagentTestProviderCurrent = nil\n\t\t}\n\t\tbootSubagentTestProviderMu.Unlock()\n\t})\n}\n\ntype bootSubagentTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n\tcontinueRef string\n}\n\nfunc (p *bootSubagentTestProvider) Name() string { return \"boot-subagent-test\" }\n\nfunc (p *bootSubagentTestProvider) setContinueRef(ref string) {\n\tp.mu.Lock()\n\tdefer p.mu.Unlock()\n\tp.continueRef = ref\n}\n\nfunc (p *bootSubagentTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tref := p.continueRef\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-1\", Name: \"review\", Arguments: `{\"task\":\"first skill task\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"first skill answer\"}, {Type: provider.ChunkDone}}\n\tcase 2:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent first done\"}, {Type: provider.ChunkDone}}\n\tcase 3:\n\t\targs, _ := json.Marshal(map[string]string{\"task\": \"second skill task\", \"continue_from\": ref})\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"review-2\", Name: \"review\", Arguments: string(args)}}}\n\tcase 4:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: errors.New(\"subagent skill failed\")}}\n\tcase 5:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent second done\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkError, Err: fmt.Errorf(\"unexpected provider call %d\", call)}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc subagentRefFromHistory(t *testing.T, msgs []provider.Message) string {\n\tt.Helper()\n\tfor _, msg := range msgs {\n\t\tif msg.Role != provider.RoleTool {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, line := range strings.Split(msg.Content, \"\\n\") {\n\t\t\tif strings.HasPrefix(line, \"Subagent reference: \") {\n\t\t\t\treturn strings.TrimSpace(strings.TrimPrefix(line, \"Subagent reference: \"))\n\t\t\t}\n\t\t}\n\t}\n\tt.Fatalf(\"no subagent reference in history: %+v\", msgs)\n\treturn \"\"\n}\n\n// TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath reproduces headless\n// `reasonix run`: a controller built via Build with NO SetSessionPath (exactly\n// what internal/cli.runAgent does) must still be able to run a `task` sub-agent.\n// Before the ephemeral fallback this failed with \"parent session is required\".\nfunc TestBuildHeadlessRunRunsTaskSubagentWithoutSessionPath(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\tregisterHeadlessTaskTestProvider()\n\tprov := &headlessTaskTestProvider{}\n\tsetHeadlessTaskTestProvider(t, prov)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"boot-headless-test\"\nmodel = \"x\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{Sink: event.Discard})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// Deliberately NOT calling SetSessionPath — this is the headless run path.\n\tif err := ctrl.Run(context.Background(), \"use a task subagent\"); err != nil {\n\t\tt.Fatalf(\"Run: %v\", err)\n\t}\n\tif got := ctrl.SessionPath(); got != \"\" {\n\t\tt.Fatalf(\"headless run should keep an empty session path, got %q\", got)\n\t}\n\n\tvar toolContent string\n\tfor _, msg := range ctrl.History() {\n\t\tif msg.Role == provider.RoleTool {\n\t\t\ttoolContent += \"\\n\" + msg.Content\n\t\t}\n\t}\n\tif strings.Contains(toolContent, \"parent session is required\") {\n\t\tt.Fatalf(\"task subagent failed in headless run mode: %s\", toolContent)\n\t}\n\tif !strings.Contains(toolContent, \"subagent answer\") {\n\t\tt.Fatalf(\"task tool result = %q, want sub-agent answer\", toolContent)\n\t}\n\tif strings.Contains(toolContent, \"Subagent reference\") {\n\t\tt.Fatalf(\"ephemeral headless run should not persist a transcript reference: %s\", toolContent)\n\t}\n}\n\nconst headlessTaskTestProviderKind = \"boot-headless-test\"\n\nvar (\n\theadlessTaskTestProviderOnce sync.Once\n\theadlessTaskTestProviderCurrent *headlessTaskTestProvider\n\theadlessTaskTestProviderMu sync.Mutex\n)\n\nfunc registerHeadlessTaskTestProvider() {\n\theadlessTaskTestProviderOnce.Do(func() {\n\t\tprovider.Register(headlessTaskTestProviderKind, func(provider.Config) (provider.Provider, error) {\n\t\t\theadlessTaskTestProviderMu.Lock()\n\t\t\tdefer headlessTaskTestProviderMu.Unlock()\n\t\t\tif headlessTaskTestProviderCurrent == nil {\n\t\t\t\treturn nil, errors.New(\"headless task test provider is not installed\")\n\t\t\t}\n\t\t\treturn headlessTaskTestProviderCurrent, nil\n\t\t})\n\t})\n}\n\nfunc setHeadlessTaskTestProvider(t *testing.T, p *headlessTaskTestProvider) {\n\tt.Helper()\n\theadlessTaskTestProviderMu.Lock()\n\theadlessTaskTestProviderCurrent = p\n\theadlessTaskTestProviderMu.Unlock()\n\tt.Cleanup(func() {\n\t\theadlessTaskTestProviderMu.Lock()\n\t\tif headlessTaskTestProviderCurrent == p {\n\t\t\theadlessTaskTestProviderCurrent = nil\n\t\t}\n\t\theadlessTaskTestProviderMu.Unlock()\n\t})\n}\n\ntype headlessTaskTestProvider struct {\n\tmu sync.Mutex\n\tcalls int\n}\n\nfunc (p *headlessTaskTestProvider) Name() string { return \"boot-headless-test\" }\n\nfunc (p *headlessTaskTestProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {\n\tp.mu.Lock()\n\tcall := p.calls\n\tp.calls++\n\tp.mu.Unlock()\n\n\tvar chunks []provider.Chunk\n\tswitch call {\n\tcase 0:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: \"task-1\", Name: \"task\", Arguments: `{\"prompt\":\"find callers\"}`}}}\n\tcase 1:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"subagent answer\"}, {Type: provider.ChunkDone}}\n\tdefault:\n\t\tchunks = []provider.Chunk{{Type: provider.ChunkText, Text: \"parent done\"}, {Type: provider.ChunkDone}}\n\t}\n\tch := make(chan provider.Chunk, len(chunks))\n\tfor _, chunk := range chunks {\n\t\tch <- chunk\n\t}\n\tclose(ch)\n\treturn ch, nil\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\nfunc TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4-flash\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want high from DeepSeek model capability\", got)\n\t}\n\tthinking, ok := gotReq[\"thinking\"].(map[string]any)\n\tif !ok || thinking[\"type\"] != \"enabled\" {\n\t\tt.Fatalf(\"thinking = %#v, want enabled\", gotReq[\"thinking\"])\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{})\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(workspace*)\"]\n`)\n\n\tconst rule = \"Bash(go test ./...)\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleCreatesWorkspaceConfigOverUserConfig(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tworkspace := robustTempDir(t)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user)\"]\n`)\n\n\tconst rule = \"Edit(src/app.go)\"\n\tres := rememberPermissionRule(workspace, rule)\n\tif !res.Saved || res.Path != filepath.Join(workspace, \"reasonix.toml\") {\n\t\tt.Fatalf(\"remember result = %+v, want saved to workspace config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule was written to user config: %v\", userCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule missing from project config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user*)\"]\n`)\n\n\tconst rule = \"Bash(go env)\"\n\tres := rememberPermissionRule(\"\", rule)\n\tif !res.Saved || res.Path != userConfig {\n\t\tt.Fatalf(\"remember result = %+v, want saved to user source config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc TestRememberPermissionRuleSkipsRuleCoveredByExistingAllow(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test:*)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test ./...)\")\n\tif res.Saved || res.CoveredBy != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"remember result = %+v, want already covered\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif len(cfg.Permissions.Allow) != 1 || cfg.Permissions.Allow[0] != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"allow rules = %v, want only existing prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRulePrunesNarrowRulesWhenSavingBroaderRule(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test ./...)\", \"Bash(go build ./...)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test:*)\")\n\tif !res.Saved || res.CoveredBy != \"\" {\n\t\tt.Fatalf(\"remember result = %+v, want saved broader rule\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test ./...)\") {\n\t\tt.Fatalf(\"narrow go test rule should be pruned: %v\", cfg.Permissions.Allow)\n\t}\n\tif !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go build ./...)\") || !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test:*)\") {\n\t\tt.Fatalf(\"allow rules = %v, want unrelated exact plus prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildWarmCodegraphIgnoresLegacyEagerTier(t *testing.T) {\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"fake launcher is a POSIX-sh script\")\n\t}\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.Mkdir(filepath.Join(dir, \".codegraph\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tlauncher := filepath.Join(dir, \"slow-codegraph\")\n\twriteFile(t, dir, \"slow-codegraph\", \"#!/bin/sh\\nif [ \\\"$1\\\" = serve ]; then sleep 5; fi\\n\")\n\tif err := os.Chmod(launcher, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"eager\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not block on warm codegraph with legacy eager tier: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildDefaultsToNearestGitRoot(t *testing.T) {\n\tisolateConfigHome(t)\n\troot := robustTempDir(t)\n\tif err := os.Mkdir(filepath.Join(root, \".git\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsubdir := filepath.Join(root, \"cmd\", \"tool\")\n\tif err := os.MkdirAll(subdir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\twriteFile(t, root, \"reasonix.toml\", `\ndefault_model = \"root-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"root-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\tt.Chdir(subdir)\n\n\tctrl, err := Build(context.Background(), Options{Model: \"root-model\"})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should load config from nearest git root: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "823fdce746d2afa51eec978f73b13b23152cc565": {"t": 1785750711.868528, "n": 35614, "h": "da57838b89fd4415", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\nfunc TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4-flash\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want high from DeepSeek model capability\", got)\n\t}\n\tthinking, ok := gotReq[\"thinking\"].(map[string]any)\n\tif !ok || thinking[\"type\"] != \"enabled\" {\n\t\tt.Fatalf(\"thinking = %#v, want enabled\", gotReq[\"thinking\"])\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{})\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(workspace*)\"]\n`)\n\n\tconst rule = \"Bash(go test ./...)\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleCreatesWorkspaceConfigOverUserConfig(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tworkspace := robustTempDir(t)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user)\"]\n`)\n\n\tconst rule = \"Edit(src/app.go)\"\n\tres := rememberPermissionRule(workspace, rule)\n\tif !res.Saved || res.Path != filepath.Join(workspace, \"reasonix.toml\") {\n\t\tt.Fatalf(\"remember result = %+v, want saved to workspace config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule was written to user config: %v\", userCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule missing from project config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user*)\"]\n`)\n\n\tconst rule = \"Bash(go env)\"\n\tres := rememberPermissionRule(\"\", rule)\n\tif !res.Saved || res.Path != userConfig {\n\t\tt.Fatalf(\"remember result = %+v, want saved to user source config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc TestRememberPermissionRuleSkipsRuleCoveredByExistingAllow(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test:*)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test ./...)\")\n\tif res.Saved || res.CoveredBy != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"remember result = %+v, want already covered\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif len(cfg.Permissions.Allow) != 1 || cfg.Permissions.Allow[0] != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"allow rules = %v, want only existing prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRulePrunesNarrowRulesWhenSavingBroaderRule(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test ./...)\", \"Bash(go build ./...)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test:*)\")\n\tif !res.Saved || res.CoveredBy != \"\" {\n\t\tt.Fatalf(\"remember result = %+v, want saved broader rule\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test ./...)\") {\n\t\tt.Fatalf(\"narrow go test rule should be pruned: %v\", cfg.Permissions.Allow)\n\t}\n\tif !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go build ./...)\") || !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test:*)\") {\n\t\tt.Fatalf(\"allow rules = %v, want unrelated exact plus prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildWarmCodegraphIgnoresLegacyEagerTier(t *testing.T) {\n\tif runtime.GOOS == \"windows\" {\n\t\tt.Skip(\"fake launcher is a POSIX-sh script\")\n\t}\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tif err := os.Mkdir(filepath.Join(dir, \".codegraph\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tlauncher := filepath.Join(dir, \"slow-codegraph\")\n\twriteFile(t, dir, \"slow-codegraph\", \"#!/bin/sh\\nif [ \\\"$1\\\" = serve ]; then sleep 5; fi\\n\")\n\tif err := os.Chmod(launcher, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"eager\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not block on warm codegraph with legacy eager tier: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildDefaultsToNearestGitRoot(t *testing.T) {\n\tisolateConfigHome(t)\n\troot := robustTempDir(t)\n\tif err := os.Mkdir(filepath.Join(root, \".git\"), 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tsubdir := filepath.Join(root, \"cmd\", \"tool\")\n\tif err := os.MkdirAll(subdir, 0o755); err != nil {\n\t\tt.Fatal(err)\n\t}\n\twriteFile(t, root, \"reasonix.toml\", `\ndefault_model = \"root-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"root-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\tt.Chdir(subdir)\n\n\tctrl, err := Build(context.Background(), Options{Model: \"root-model\"})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should load config from nearest git root: %v\", err)\n\t}\n\tdefer ctrl.Close()\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "f311a475d4212ef8bc3459a8173356bb46a0808e": {"t": 1785750713.1282935, "n": 33736, "h": "43772035bbb9aad6", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\nfunc TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4-flash\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want high from DeepSeek model capability\", got)\n\t}\n\tthinking, ok := gotReq[\"thinking\"].(map[string]any)\n\tif !ok || thinking[\"type\"] != \"enabled\" {\n\t\tt.Fatalf(\"thinking = %#v, want enabled\", gotReq[\"thinking\"])\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{})\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(workspace*)\"]\n`)\n\n\tconst rule = \"Bash(go test ./...)\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleCreatesWorkspaceConfigOverUserConfig(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tworkspace := robustTempDir(t)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user)\"]\n`)\n\n\tconst rule = \"Edit(src/app.go)\"\n\tres := rememberPermissionRule(workspace, rule)\n\tif !res.Saved || res.Path != filepath.Join(workspace, \"reasonix.toml\") {\n\t\tt.Fatalf(\"remember result = %+v, want saved to workspace config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule was written to user config: %v\", userCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"workspace rule missing from project config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"Bash(user*)\"]\n`)\n\n\tconst rule = \"Bash(go env)\"\n\tres := rememberPermissionRule(\"\", rule)\n\tif !res.Saved || res.Path != userConfig {\n\t\tt.Fatalf(\"remember result = %+v, want saved to user source config\", res)\n\t}\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc TestRememberPermissionRuleSkipsRuleCoveredByExistingAllow(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test:*)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test ./...)\")\n\tif res.Saved || res.CoveredBy != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"remember result = %+v, want already covered\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif len(cfg.Permissions.Allow) != 1 || cfg.Permissions.Allow[0] != \"Bash(go test:*)\" {\n\t\tt.Fatalf(\"allow rules = %v, want only existing prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRulePrunesNarrowRulesWhenSavingBroaderRule(t *testing.T) {\n\tworkspace := robustTempDir(t)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"Bash(go test ./...)\", \"Bash(go build ./...)\"]\n`)\n\n\tres := rememberPermissionRule(workspace, \"Bash(go test:*)\")\n\tif !res.Saved || res.CoveredBy != \"\" {\n\t\tt.Fatalf(\"remember result = %+v, want saved broader rule\", res)\n\t}\n\tcfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test ./...)\") {\n\t\tt.Fatalf(\"narrow go test rule should be pruned: %v\", cfg.Permissions.Allow)\n\t}\n\tif !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go build ./...)\") || !hasPermissionRule(cfg.Permissions.Allow, \"Bash(go test:*)\") {\n\t\tt.Fatalf(\"allow rules = %v, want unrelated exact plus prefix\", cfg.Permissions.Allow)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "bbf76100e67e4cecbdcf37f71117f107e3e1b57a": {"t": 1785750714.3997684, "n": 30998, "h": "8ea8156e475fb761", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\nfunc TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4-flash\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want high from DeepSeek model capability\", got)\n\t}\n\tthinking, ok := gotReq[\"thinking\"].(map[string]any)\n\tif !ok || thinking[\"type\"] != \"enabled\" {\n\t\tt.Fatalf(\"thinking = %#v, want enabled\", gotReq[\"thinking\"])\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), netclient.ProxySpec{})\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(workspace*)\"]\n`)\n\n\tconst rule = \"bash=go test ./...\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"bash(user*)\"]\n`)\n\n\tconst rule = \"bash=go env\"\n\trememberPermissionRule(\"\", rule)\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "ba094b9f5ee23162319657e040fcbda154d53729": {"t": 1785750715.647437, "n": 30948, "h": "3d95696fe833deb0", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\nfunc TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4-flash\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want high from DeepSeek model capability\", got)\n\t}\n\tthinking, ok := gotReq[\"thinking\"].(map[string]any)\n\tif !ok || thinking[\"type\"] != \"enabled\" {\n\t\tt.Fatalf(\"thinking = %#v, want enabled\", gotReq[\"thinking\"])\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t), \"\")\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(workspace*)\"]\n`)\n\n\tconst rule = \"bash=go test ./...\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"bash(user*)\"]\n`)\n\n\tconst rule = \"bash=go env\"\n\trememberPermissionRule(\"\", rule)\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "304e1152cddce0b025861b5accc77c52b20aee04": {"t": 1785750716.891236, "n": 30944, "h": "20d1df2d5adc86cb", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\nfunc TestNewProviderAppliesModelReasoningProtocol(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"deepseek-proxy\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"deepseek-v4-flash\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"high\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want high from DeepSeek model capability\", got)\n\t}\n\tthinking, ok := gotReq[\"thinking\"].(map[string]any)\n\tif !ok || thinking[\"type\"] != \"enabled\" {\n\t\tt.Fatalf(\"thinking = %#v, want enabled\", gotReq[\"thinking\"])\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t))\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(workspace*)\"]\n`)\n\n\tconst rule = \"bash=go test ./...\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"bash(user*)\"]\n`)\n\n\tconst rule = \"bash=go env\"\n\trememberPermissionRule(\"\", rule)\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "bf0c67db5a3d651413e3234029fc81865caa932a": {"t": 1785750718.2257953, "n": 29650, "h": "b9963a4de118d0a9", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, 120*time.Second, builtin.SearchSpec{}, &stderr, robustTempDir(t))\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(workspace*)\"]\n`)\n\n\tconst rule = \"bash=go test ./...\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"bash(user*)\"]\n`)\n\n\tconst rule = \"bash=go env\"\n\trememberPermissionRule(\"\", rule)\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "38e4c5a66d45daacca41e3b4ee7b84fa91aea478": {"t": 1785750719.4473248, "n": 29633, "h": "8492d594c8fe56cb", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, builtin.SearchSpec{}, &stderr, robustTempDir(t))\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsExcludedSkillRootsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\texcluded := filepath.Join(home, \".agents\", \"skills\")\n\twriteFile(t, home, \".reasonix/skills/keep.md\", \"---\\ndescription: keep\\n---\\nplaybook\")\n\twriteFile(t, home, \".agents/skills/noisy.md\", \"---\\ndescription: noisy\\n---\\nplaybook\")\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\nexcluded_paths = [%q]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, excluded))\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"noisy\" {\n\t\t\tt.Fatalf(\"excluded skill should not be executable: %v\", ctrl.Skills())\n\t\t}\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"noisy\") {\n\t\tt.Fatalf(\"excluded skill name should be omitted from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"keep\") {\n\t\tt.Fatalf(\"non-excluded skill should remain in system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(workspace*)\"]\n`)\n\n\tconst rule = \"bash=go test ./...\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"bash(user*)\"]\n`)\n\n\tconst rule = \"bash=go env\"\n\trememberPermissionRule(\"\", rule)\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "55e131b2f850478833d475447d1a9a8b68a38756": {"t": 1785750720.7135603, "n": 28306, "h": "3418bb1851a715af", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{robustTempDir(t)}, sandbox.Spec{}, builtin.SearchSpec{}, &stderr, robustTempDir(t))\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := robustTempDir(t)\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tworkspace := robustTempDir(t)\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(workspace*)\"]\n`)\n\n\tconst rule = \"bash=go test ./...\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := robustTempDir(t)\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"bash(user*)\"]\n`)\n\n\tconst rule = \"bash=go env\"\n\trememberPermissionRule(\"\", rule)\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := robustTempDir(t)\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := robustTempDir(t)\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := robustTempDir(t)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := robustTempDir(t)\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := robustTempDir(t)\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "2384eb4adcd7d653b52cc9eb98b371ced6b6653e": {"t": 1785750721.9550989, "n": 28191, "h": "1fcecefcf2977d10", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{t.TempDir()}, sandbox.Spec{}, builtin.SearchSpec{}, &stderr, t.TempDir())\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := t.TempDir()\n\tworkspace := t.TempDir()\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(workspace*)\"]\n`)\n\n\tconst rule = \"bash=go test ./...\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := t.TempDir()\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"bash(user*)\"]\n`)\n\n\tconst rule = \"bash=go env\"\n\trememberPermissionRule(\"\", rule)\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := t.TempDir()\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := t.TempDir()\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\t// Pass the project root via WorkspaceRoot instead of t.Chdir: changing the\n\t// process cwd into a t.TempDir makes Windows refuse to remove that dir during\n\t// test cleanup (the cwd counts as \"in use\"), which is the only thing this test\n\t// failed on. WorkspaceRoot loads the same config without touching the cwd.\n\tctrl, err := Build(context.Background(), Options{Sink: sink, WorkspaceRoot: proj})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := t.TempDir()\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "121f7085e4c490cd21e31a0f058117b78965e221": {"t": 1785750723.191426, "n": 27870, "h": "3df9152f5a4643e9", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{t.TempDir()}, sandbox.Spec{}, builtin.SearchSpec{}, &stderr, t.TempDir())\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := t.TempDir()\n\tworkspace := t.TempDir()\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(workspace*)\"]\n`)\n\n\tconst rule = \"bash=go test ./...\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := t.TempDir()\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"bash(user*)\"]\n`)\n\n\tconst rule = \"bash=go env\"\n\trememberPermissionRule(\"\", rule)\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := t.TempDir()\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := t.TempDir()\n\tt.Chdir(proj)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := t.TempDir()\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), an empty/missing tier defaults to background\n// (connects after session start without blocking chat), and unknown non-empty\n// values fall back to lazy so a typo never forces unwanted background connects.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty defaults to background\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 2 || bg[0].Name != \"b1\" || bg[1].Name != \"default\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1, default] preserving input order\", bg)\n\t}\n\tif len(lazy) != 1 || lazy[0].Name != \"l1\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1]\", lazy)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-eager\"\ncommand = \"reasonix-missing-legacy-eager-mcp\"\ntier = \"eager\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-eager\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-eager\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy eager plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy eager tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildMigratesLegacyLazyTierToBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"legacy-lazy\"\ncommand = \"reasonix-missing-legacy-lazy-mcp\"\ntier = \"lazy\"\n`)\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"legacy-lazy\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"legacy-lazy\" {\n\t\tt.Fatalf(\"failures = %+v, want background startup failure for migrated legacy lazy plugin\", failures)\n\t}\n\traw, err := os.ReadFile(filepath.Join(dir, \"reasonix.toml\"))\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif strings.Contains(string(raw), \"\\ntier\") {\n\t\tt.Fatalf(\"legacy lazy tier should be removed during load:\\n%s\", raw)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\nfunc TestBuildMigratesLegacyEagerBeforeStatsDemotion(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfailures := waitForMCPFailure(t, ctrl.Host(), \"slowserver\", 2*time.Second)\n\tif len(failures) != 1 || failures[0].Name != \"slowserver\" {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want background startup failure for migrated plugin\", failures)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif foundDemoteNotice {\n\t\tt.Fatalf(\"legacy tier should be migrated before demotion logic; got notices %+v\", notices)\n\t}\n}\n\nfunc waitForMCPFailure(t *testing.T, h *plugin.Host, name string, timeout time.Duration) []plugin.Failure {\n\tt.Helper()\n\tdeadline := time.Now().Add(timeout)\n\tfor {\n\t\tfailures := h.Failures()\n\t\tfor _, f := range failures {\n\t\t\tif f.Name == name {\n\t\t\t\treturn failures\n\t\t\t}\n\t\t}\n\t\tif time.Now().After(deadline) {\n\t\t\treturn failures\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "ad3fae6db245c3348f731116111b5d818ee383b9": {"t": 1785750724.4379325, "n": 31350, "h": "cfbfa0f476512e0e", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{t.TempDir()}, sandbox.Spec{}, builtin.SearchSpec{}, &stderr, t.TempDir())\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildRecordsMCPStartupFailure(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"missing\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`)\n\tvar notices []event.Event\n\tctrl, err := Build(context.Background(), Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not fail when an MCP server is unavailable: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tfailures := ctrl.Host().Failures()\n\tif len(failures) != 1 || failures[0].Name != \"missing\" {\n\t\tt.Fatalf(\"failures = %+v, want missing\", failures)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"failed to start\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing startup warning notice: %+v\", notices)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := t.TempDir()\n\tworkspace := t.TempDir()\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(workspace*)\"]\n`)\n\n\tconst rule = \"bash=go test ./...\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := t.TempDir()\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"bash(user*)\"]\n`)\n\n\tconst rule = \"bash=go env\"\n\trememberPermissionRule(\"\", rule)\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := t.TempDir()\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\nfunc TestBuildMigratesLegacySessionsFromConfigSessionDir(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \"xdg-config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tproj := t.TempDir()\n\tt.Chdir(proj)\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\n\tlegacyDir := config.SessionDir()\n\twriteFile(t, legacyDir, \"custom-root.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from redirected config root\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi from redirected root\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsessionPath := filepath.Join(config.SessionDir(), \"custom-root.jsonl\")\n\tdata, err := os.ReadFile(sessionPath)\n\tif err != nil {\n\t\tt.Fatalf(\"legacy config-root session not imported to %s: %v\", sessionPath, err)\n\t}\n\tif !strings.Contains(string(data), \"hello from redirected config root\") {\n\t\tt.Fatalf(\"migrated session missing legacy content:\\n%s\", data)\n\t}\n\tif _, err := os.Stat(filepath.Join(config.SessionDir(), \".legacy-imported.v0-events-config\")); err != nil {\n\t\tt.Fatalf(\"config-root legacy import marker missing: %v\", err)\n\t}\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") && strings.Contains(n, legacyDir) {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no config-root session-import notice emitted; got %v\", notices)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := t.TempDir()\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), and a missing/unknown tier value falls into\n// lazy — the project default that keeps adding a plugin from ever slowing the\n// next launch.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty must default to lazy\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 1 || bg[0].Name != \"b1\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1]\", bg)\n\t}\n\t// Lazy holds both the explicit lazy entry and the default-fallback one, in\n\t// the order they appeared in the input — proves the empty-tier default\n\t// flows through partition without reshuffling.\n\tif len(lazy) != 2 || lazy[0].Name != \"l1\" || lazy[1].Name != \"default\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1, default] preserving input order\", lazy)\n\t}\n}\n\n// TestBuildEagerStartsAtBoot proves an eager-tier plugin actually completes\n// its handshake on the boot critical path: Host.ServerNames() must include the\n// plugin after Build returns. We point the plugin at this test binary running\n// as a stdio MCP helper (see TestHelperProcess), so the spawn is real but\n// deterministic and hermetic — no external MCP server required on PATH.\nfunc TestBuildEagerStartsAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"eagermock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"eager\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tnames := ctrl.Host().ServerNames()\n\tfound := false\n\tfor _, n := range names {\n\t\tif n == \"eagermock\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"eager plugin missing from Host.ServerNames() = %v — boot did not block on its handshake\", names)\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for a healthy eager plugin\", got)\n\t}\n}\n\n// TestBuildLazyDoesNotConnectAtBoot is the inverse of the eager test: a\n// lazy-tier plugin must NOT trigger a subprocess handshake during Build, so\n// the boot critical path stays empty even with a slow-to-spawn server in\n// config. We assert through Host.ServerNames() (must not list the plugin) and\n// Host.Failures() (lazy plugins never appear here — a failure surfaces only on\n// first model use, not at boot). The placeholder tool registration itself is\n// covered by internal/plugin/lazy_test.go's TestLazy* suite; the Registry has\n// no public accessor on Controller, so at this layer we pin the load-bearing\n// boot-time invariant — no spawn — rather than re-validating the placeholder.\nfunc TestBuildLazyDoesNotConnectAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"lazymock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"lazy\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"lazymock\" {\n\t\t\tt.Fatalf(\"lazy plugin %q appeared in Host.ServerNames() — boot connected it eagerly\", n)\n\t\t}\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — lazy plugins must not even attempt a boot-time spawn\", got)\n\t}\n\t// The configured plugin is still recognised as a configured-but-disconnected\n\t// server, the same signal /mcp uses to render its \"lazy, will connect on\n\t// first use\" line — proves the entry made it through partition into the\n\t// lazy bucket rather than being dropped.\n\tdconn := ctrl.DisconnectedMCPNames()\n\tfoundDisconnected := false\n\tfor _, n := range dconn {\n\t\tif n == \"lazymock\" {\n\t\t\tfoundDisconnected = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDisconnected {\n\t\tt.Fatalf(\"DisconnectedMCPNames() = %v, want it to include the lazy plugin (configured but not connected)\", dconn)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\n// TestBuildAutoDemoteFromStats proves the Phase 5 telemetry → Phase 4 tier\n// bridge: three consecutive over-budget startup samples must demote an\n// eager-tier plugin to lazy at the *next* boot, so the user pays for a slow\n// MCP server at most a handful of starts. We pre-seed three 30s samples for\n// \"slowserver\" (well above 2 * DefaultStartupBudget = 10s) via the public\n// RecordStartup API, then Build a config that declares \"slowserver\" eager and\n// verify (a) boot did NOT block on its handshake — the plugin is absent from\n// Host.ServerNames() — and (b) a Notice carrying the demote reason fired so\n// the user understands why their explicit \"eager\" was ignored this session.\nfunc TestBuildAutoDemoteFromStats(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"slowserver\" {\n\t\t\tt.Fatalf(\"demoted plugin %q still appeared in Host.ServerNames() — auto-demote did not move it out of the eager bucket\", n)\n\t\t}\n\t}\n\t// Crucially the missing-binary command never ran: a real eager attempt\n\t// would have surfaced as a Failure with a spawn error. An empty failures\n\t// list proves boot skipped the spawn entirely, not that it tried and\n\t// silently swallowed the error.\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — demoted plugin should never have been spawned\", got)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDemoteNotice {\n\t\tt.Fatalf(\"no demote notice surfaced; got %+v\", notices)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "d5c5d3508f625d4ad1f222b76a31f7805d099893": {"t": 1785750725.686764, "n": 29538, "h": "a6cfc7761c891dd3", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\t\"reasonix/internal/sandbox\"\n\t\"reasonix/internal/tool\"\n\t\"reasonix/internal/tool/builtin\"\n\n\t// Blank import registers the provider kind the same way cmd/reasonix's main\n\t// does; importing builtin above registers the built-in tools.\n\t_ \"reasonix/internal/provider/openai\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestAddBuiltinsWithWorkspaceRootKeepsSessionTools(t *testing.T) {\n\treg := tool.NewRegistry()\n\tvar stderr bytes.Buffer\n\taddBuiltins(reg, nil, []string{t.TempDir()}, sandbox.Spec{}, builtin.SearchSpec{}, &stderr, t.TempDir())\n\tfor _, name := range []string{\n\t\t\"todo_write\",\n\t\t\"complete_step\",\n\t\t\"bash_output\",\n\t\t\"kill_shell\",\n\t\t\"wait\",\n\t\t\"notebook_edit\",\n\t} {\n\t\tif _, ok := reg.Get(name); !ok {\n\t\t\tt.Fatalf(\"workspace builtins missing %q; got %v\", name, reg.Names())\n\t\t}\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildRecordsMCPStartupFailure(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"missing\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`)\n\tvar notices []event.Event\n\tctrl, err := Build(context.Background(), Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not fail when an MCP server is unavailable: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tfailures := ctrl.Host().Failures()\n\tif len(failures) != 1 || failures[0].Name != \"missing\" {\n\t\tt.Fatalf(\"failures = %+v, want missing\", failures)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"failed to start\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing startup warning notice: %+v\", notices)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := t.TempDir()\n\tworkspace := t.TempDir()\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(workspace*)\"]\n`)\n\n\tconst rule = \"bash=go test ./...\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := t.TempDir()\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"bash(user*)\"]\n`)\n\n\tconst rule = \"bash=go env\"\n\trememberPermissionRule(\"\", rule)\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := t.TempDir()\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := t.TempDir()\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), and a missing/unknown tier value falls into\n// lazy — the project default that keeps adding a plugin from ever slowing the\n// next launch.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty must default to lazy\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 1 || bg[0].Name != \"b1\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1]\", bg)\n\t}\n\t// Lazy holds both the explicit lazy entry and the default-fallback one, in\n\t// the order they appeared in the input — proves the empty-tier default\n\t// flows through partition without reshuffling.\n\tif len(lazy) != 2 || lazy[0].Name != \"l1\" || lazy[1].Name != \"default\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1, default] preserving input order\", lazy)\n\t}\n}\n\n// TestBuildEagerStartsAtBoot proves an eager-tier plugin actually completes\n// its handshake on the boot critical path: Host.ServerNames() must include the\n// plugin after Build returns. We point the plugin at this test binary running\n// as a stdio MCP helper (see TestHelperProcess), so the spawn is real but\n// deterministic and hermetic — no external MCP server required on PATH.\nfunc TestBuildEagerStartsAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"eagermock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"eager\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tnames := ctrl.Host().ServerNames()\n\tfound := false\n\tfor _, n := range names {\n\t\tif n == \"eagermock\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"eager plugin missing from Host.ServerNames() = %v — boot did not block on its handshake\", names)\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for a healthy eager plugin\", got)\n\t}\n}\n\n// TestBuildLazyDoesNotConnectAtBoot is the inverse of the eager test: a\n// lazy-tier plugin must NOT trigger a subprocess handshake during Build, so\n// the boot critical path stays empty even with a slow-to-spawn server in\n// config. We assert through Host.ServerNames() (must not list the plugin) and\n// Host.Failures() (lazy plugins never appear here — a failure surfaces only on\n// first model use, not at boot). The placeholder tool registration itself is\n// covered by internal/plugin/lazy_test.go's TestLazy* suite; the Registry has\n// no public accessor on Controller, so at this layer we pin the load-bearing\n// boot-time invariant — no spawn — rather than re-validating the placeholder.\nfunc TestBuildLazyDoesNotConnectAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"lazymock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"lazy\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"lazymock\" {\n\t\t\tt.Fatalf(\"lazy plugin %q appeared in Host.ServerNames() — boot connected it eagerly\", n)\n\t\t}\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — lazy plugins must not even attempt a boot-time spawn\", got)\n\t}\n\t// The configured plugin is still recognised as a configured-but-disconnected\n\t// server, the same signal /mcp uses to render its \"lazy, will connect on\n\t// first use\" line — proves the entry made it through partition into the\n\t// lazy bucket rather than being dropped.\n\tdconn := ctrl.DisconnectedMCPNames()\n\tfoundDisconnected := false\n\tfor _, n := range dconn {\n\t\tif n == \"lazymock\" {\n\t\t\tfoundDisconnected = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDisconnected {\n\t\tt.Fatalf(\"DisconnectedMCPNames() = %v, want it to include the lazy plugin (configured but not connected)\", dconn)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\n// TestBuildAutoDemoteFromStats proves the Phase 5 telemetry → Phase 4 tier\n// bridge: three consecutive over-budget startup samples must demote an\n// eager-tier plugin to lazy at the *next* boot, so the user pays for a slow\n// MCP server at most a handful of starts. We pre-seed three 30s samples for\n// \"slowserver\" (well above 2 * DefaultStartupBudget = 10s) via the public\n// RecordStartup API, then Build a config that declares \"slowserver\" eager and\n// verify (a) boot did NOT block on its handshake — the plugin is absent from\n// Host.ServerNames() — and (b) a Notice carrying the demote reason fired so\n// the user understands why their explicit \"eager\" was ignored this session.\nfunc TestBuildAutoDemoteFromStats(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"slowserver\" {\n\t\t\tt.Fatalf(\"demoted plugin %q still appeared in Host.ServerNames() — auto-demote did not move it out of the eager bucket\", n)\n\t\t}\n\t}\n\t// Crucially the missing-binary command never ran: a real eager attempt\n\t// would have surfaced as a Failure with a spawn error. An empty failures\n\t// list proves boot skipped the spawn entirely, not that it tried and\n\t// silently swallowed the error.\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — demoted plugin should never have been spawned\", got)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDemoteNotice {\n\t\tt.Fatalf(\"no demote notice surfaced; got %+v\", notices)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "c0bda7ce577f66ebfb31688d98feae3e0b8a5011": {"t": 1785750726.913868, "n": 29046, "h": "26db54ea2225c6e6", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\n\t// Blank imports register the provider kind and built-in tools the same way\n\t// cmd/reasonix's main does; without them Build sees an empty provider\n\t// registry and a bare tool set.\n\t_ \"reasonix/internal/provider/openai\"\n\t_ \"reasonix/internal/tool/builtin\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\nfunc TestNewProviderAppliesConfiguredDefaultEffort(t *testing.T) {\n\tvar gotReq map[string]any\n\tsrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\tif err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {\n\t\t\tt.Fatalf(\"decode request: %v\", err)\n\t\t}\n\t\tw.Header().Set(\"Content-Type\", \"text/event-stream\")\n\t\t_, _ = w.Write([]byte(\"data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"ok\\\"}}]}\\n\\ndata: [DONE]\\n\\n\"))\n\t}))\n\tdefer srv.Close()\n\n\tp, err := NewProvider(&config.ProviderEntry{\n\t\tName: \"custom\",\n\t\tKind: \"openai\",\n\t\tBaseURL: srv.URL,\n\t\tModel: \"m\",\n\t\tSupportedEfforts: []string{\"low\", \"medium\", \"high\"},\n\t\tDefaultEffort: \"MEDIUM\",\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"NewProvider: %v\", err)\n\t}\n\tch, err := p.Stream(context.Background(), provider.Request{\n\t\tMessages: []provider.Message{{Role: provider.RoleUser, Content: \"hi\"}},\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Stream: %v\", err)\n\t}\n\tfor chunk := range ch {\n\t\tif chunk.Type == provider.ChunkError {\n\t\t\tt.Fatalf(\"stream error: %v\", chunk.Err)\n\t\t}\n\t}\n\tif got := gotReq[\"reasoning_effort\"]; got != \"medium\" {\n\t\tt.Fatalf(\"reasoning_effort = %#v, want medium from default_effort\", got)\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildRecordsMCPStartupFailure(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"missing\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`)\n\tvar notices []event.Event\n\tctrl, err := Build(context.Background(), Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not fail when an MCP server is unavailable: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tfailures := ctrl.Host().Failures()\n\tif len(failures) != 1 || failures[0].Name != \"missing\" {\n\t\tt.Fatalf(\"failures = %+v, want missing\", failures)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"failed to start\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing startup warning notice: %+v\", notices)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := t.TempDir()\n\tworkspace := t.TempDir()\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(workspace*)\"]\n`)\n\n\tconst rule = \"bash=go test ./...\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := t.TempDir()\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"bash(user*)\"]\n`)\n\n\tconst rule = \"bash=go env\"\n\trememberPermissionRule(\"\", rule)\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := t.TempDir()\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := t.TempDir()\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), and a missing/unknown tier value falls into\n// lazy — the project default that keeps adding a plugin from ever slowing the\n// next launch.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty must default to lazy\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 1 || bg[0].Name != \"b1\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1]\", bg)\n\t}\n\t// Lazy holds both the explicit lazy entry and the default-fallback one, in\n\t// the order they appeared in the input — proves the empty-tier default\n\t// flows through partition without reshuffling.\n\tif len(lazy) != 2 || lazy[0].Name != \"l1\" || lazy[1].Name != \"default\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1, default] preserving input order\", lazy)\n\t}\n}\n\n// TestBuildEagerStartsAtBoot proves an eager-tier plugin actually completes\n// its handshake on the boot critical path: Host.ServerNames() must include the\n// plugin after Build returns. We point the plugin at this test binary running\n// as a stdio MCP helper (see TestHelperProcess), so the spawn is real but\n// deterministic and hermetic — no external MCP server required on PATH.\nfunc TestBuildEagerStartsAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"eagermock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"eager\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tnames := ctrl.Host().ServerNames()\n\tfound := false\n\tfor _, n := range names {\n\t\tif n == \"eagermock\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"eager plugin missing from Host.ServerNames() = %v — boot did not block on its handshake\", names)\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for a healthy eager plugin\", got)\n\t}\n}\n\n// TestBuildLazyDoesNotConnectAtBoot is the inverse of the eager test: a\n// lazy-tier plugin must NOT trigger a subprocess handshake during Build, so\n// the boot critical path stays empty even with a slow-to-spawn server in\n// config. We assert through Host.ServerNames() (must not list the plugin) and\n// Host.Failures() (lazy plugins never appear here — a failure surfaces only on\n// first model use, not at boot). The placeholder tool registration itself is\n// covered by internal/plugin/lazy_test.go's TestLazy* suite; the Registry has\n// no public accessor on Controller, so at this layer we pin the load-bearing\n// boot-time invariant — no spawn — rather than re-validating the placeholder.\nfunc TestBuildLazyDoesNotConnectAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"lazymock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"lazy\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"lazymock\" {\n\t\t\tt.Fatalf(\"lazy plugin %q appeared in Host.ServerNames() — boot connected it eagerly\", n)\n\t\t}\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — lazy plugins must not even attempt a boot-time spawn\", got)\n\t}\n\t// The configured plugin is still recognised as a configured-but-disconnected\n\t// server, the same signal /mcp uses to render its \"lazy, will connect on\n\t// first use\" line — proves the entry made it through partition into the\n\t// lazy bucket rather than being dropped.\n\tdconn := ctrl.DisconnectedMCPNames()\n\tfoundDisconnected := false\n\tfor _, n := range dconn {\n\t\tif n == \"lazymock\" {\n\t\t\tfoundDisconnected = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDisconnected {\n\t\tt.Fatalf(\"DisconnectedMCPNames() = %v, want it to include the lazy plugin (configured but not connected)\", dconn)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\n// TestBuildAutoDemoteFromStats proves the Phase 5 telemetry → Phase 4 tier\n// bridge: three consecutive over-budget startup samples must demote an\n// eager-tier plugin to lazy at the *next* boot, so the user pays for a slow\n// MCP server at most a handful of starts. We pre-seed three 30s samples for\n// \"slowserver\" (well above 2 * DefaultStartupBudget = 10s) via the public\n// RecordStartup API, then Build a config that declares \"slowserver\" eager and\n// verify (a) boot did NOT block on its handshake — the plugin is absent from\n// Host.ServerNames() — and (b) a Notice carrying the demote reason fired so\n// the user understands why their explicit \"eager\" was ignored this session.\nfunc TestBuildAutoDemoteFromStats(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"slowserver\" {\n\t\t\tt.Fatalf(\"demoted plugin %q still appeared in Host.ServerNames() — auto-demote did not move it out of the eager bucket\", n)\n\t\t}\n\t}\n\t// Crucially the missing-binary command never ran: a real eager attempt\n\t// would have surfaced as a Failure with a spawn error. An empty failures\n\t// list proves boot skipped the spawn entirely, not that it tried and\n\t// silently swallowed the error.\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — demoted plugin should never have been spawned\", got)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDemoteNotice {\n\t\tt.Fatalf(\"no demote notice surfaced; got %+v\", notices)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "938ea40c922ce226961dc21ffdaadfdb4c77f746": {"t": 1785750728.137791, "n": 27790, "h": "ed7b1970d9e072c6", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\n\t// Blank imports register the provider kind and built-in tools the same way\n\t// cmd/reasonix's main does; without them Build sees an empty provider\n\t// registry and a bare tool set.\n\t_ \"reasonix/internal/provider/openai\"\n\t_ \"reasonix/internal/tool/builtin\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildRecordsMCPStartupFailure(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"missing\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`)\n\tvar notices []event.Event\n\tctrl, err := Build(context.Background(), Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not fail when an MCP server is unavailable: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tfailures := ctrl.Host().Failures()\n\tif len(failures) != 1 || failures[0].Name != \"missing\" {\n\t\tt.Fatalf(\"failures = %+v, want missing\", failures)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"failed to start\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing startup warning notice: %+v\", notices)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := t.TempDir()\n\tworkspace := t.TempDir()\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(workspace*)\"]\n`)\n\n\tconst rule = \"bash=go test ./...\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := t.TempDir()\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"bash(user*)\"]\n`)\n\n\tconst rule = \"bash=go env\"\n\trememberPermissionRule(\"\", rule)\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := t.TempDir()\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(config.UserCredentialsPath()); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"credentials store missing migrated key: %q (err %v)\", data, err)\n\t}\n\tif _, err := os.Stat(filepath.Join(home, \".env\")); !os.IsNotExist(err) {\n\t\tt.Errorf(\"migration must not write the user's ~/.env, stat err=%v\", err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := t.TempDir()\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), and a missing/unknown tier value falls into\n// lazy — the project default that keeps adding a plugin from ever slowing the\n// next launch.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty must default to lazy\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 1 || bg[0].Name != \"b1\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1]\", bg)\n\t}\n\t// Lazy holds both the explicit lazy entry and the default-fallback one, in\n\t// the order they appeared in the input — proves the empty-tier default\n\t// flows through partition without reshuffling.\n\tif len(lazy) != 2 || lazy[0].Name != \"l1\" || lazy[1].Name != \"default\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1, default] preserving input order\", lazy)\n\t}\n}\n\n// TestBuildEagerStartsAtBoot proves an eager-tier plugin actually completes\n// its handshake on the boot critical path: Host.ServerNames() must include the\n// plugin after Build returns. We point the plugin at this test binary running\n// as a stdio MCP helper (see TestHelperProcess), so the spawn is real but\n// deterministic and hermetic — no external MCP server required on PATH.\nfunc TestBuildEagerStartsAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"eagermock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"eager\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tnames := ctrl.Host().ServerNames()\n\tfound := false\n\tfor _, n := range names {\n\t\tif n == \"eagermock\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"eager plugin missing from Host.ServerNames() = %v — boot did not block on its handshake\", names)\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for a healthy eager plugin\", got)\n\t}\n}\n\n// TestBuildLazyDoesNotConnectAtBoot is the inverse of the eager test: a\n// lazy-tier plugin must NOT trigger a subprocess handshake during Build, so\n// the boot critical path stays empty even with a slow-to-spawn server in\n// config. We assert through Host.ServerNames() (must not list the plugin) and\n// Host.Failures() (lazy plugins never appear here — a failure surfaces only on\n// first model use, not at boot). The placeholder tool registration itself is\n// covered by internal/plugin/lazy_test.go's TestLazy* suite; the Registry has\n// no public accessor on Controller, so at this layer we pin the load-bearing\n// boot-time invariant — no spawn — rather than re-validating the placeholder.\nfunc TestBuildLazyDoesNotConnectAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"lazymock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"lazy\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"lazymock\" {\n\t\t\tt.Fatalf(\"lazy plugin %q appeared in Host.ServerNames() — boot connected it eagerly\", n)\n\t\t}\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — lazy plugins must not even attempt a boot-time spawn\", got)\n\t}\n\t// The configured plugin is still recognised as a configured-but-disconnected\n\t// server, the same signal /mcp uses to render its \"lazy, will connect on\n\t// first use\" line — proves the entry made it through partition into the\n\t// lazy bucket rather than being dropped.\n\tdconn := ctrl.DisconnectedMCPNames()\n\tfoundDisconnected := false\n\tfor _, n := range dconn {\n\t\tif n == \"lazymock\" {\n\t\t\tfoundDisconnected = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDisconnected {\n\t\tt.Fatalf(\"DisconnectedMCPNames() = %v, want it to include the lazy plugin (configured but not connected)\", dconn)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\n// TestBuildAutoDemoteFromStats proves the Phase 5 telemetry → Phase 4 tier\n// bridge: three consecutive over-budget startup samples must demote an\n// eager-tier plugin to lazy at the *next* boot, so the user pays for a slow\n// MCP server at most a handful of starts. We pre-seed three 30s samples for\n// \"slowserver\" (well above 2 * DefaultStartupBudget = 10s) via the public\n// RecordStartup API, then Build a config that declares \"slowserver\" eager and\n// verify (a) boot did NOT block on its handshake — the plugin is absent from\n// Host.ServerNames() — and (b) a Notice carrying the demote reason fired so\n// the user understands why their explicit \"eager\" was ignored this session.\nfunc TestBuildAutoDemoteFromStats(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"slowserver\" {\n\t\t\tt.Fatalf(\"demoted plugin %q still appeared in Host.ServerNames() — auto-demote did not move it out of the eager bucket\", n)\n\t\t}\n\t}\n\t// Crucially the missing-binary command never ran: a real eager attempt\n\t// would have surfaced as a Failure with a spawn error. An empty failures\n\t// list proves boot skipped the spawn entirely, not that it tried and\n\t// silently swallowed the error.\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — demoted plugin should never have been spawned\", got)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDemoteNotice {\n\t\tt.Fatalf(\"no demote notice surfaced; got %+v\", notices)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "9619220d97dfc2373bf3251aa7aa89e4393ec5c6": {"t": 1785750729.3748624, "n": 27626, "h": "ee2152deaca2c0a6", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\n\t// Blank imports register the provider kind and built-in tools the same way\n\t// cmd/reasonix's main does; without them Build sees an empty provider\n\t// registry and a bare tool set.\n\t_ \"reasonix/internal/provider/openai\"\n\t_ \"reasonix/internal/tool/builtin\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildRecordsMCPStartupFailure(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"missing\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`)\n\tvar notices []event.Event\n\tctrl, err := Build(context.Background(), Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not fail when an MCP server is unavailable: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tfailures := ctrl.Host().Failures()\n\tif len(failures) != 1 || failures[0].Name != \"missing\" {\n\t\tt.Fatalf(\"failures = %+v, want missing\", failures)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"failed to start\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing startup warning notice: %+v\", notices)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\nfunc TestRememberPermissionRuleUsesWorkspaceRoot(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := t.TempDir()\n\tworkspace := t.TempDir()\n\tt.Chdir(cwd)\n\twriteFile(t, cwd, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(cwd*)\"]\n`)\n\twriteFile(t, workspace, \"reasonix.toml\", `\n[permissions]\nallow = [\"bash(workspace*)\"]\n`)\n\n\tconst rule = \"bash=go test ./...\"\n\trememberPermissionRule(workspace, rule)\n\n\tcwdCfg := config.LoadForEdit(filepath.Join(cwd, \"reasonix.toml\"))\n\tif hasPermissionRule(cwdCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule was written to cwd config: %v\", cwdCfg.Permissions.Allow)\n\t}\n\tworkspaceCfg := config.LoadForEdit(filepath.Join(workspace, \"reasonix.toml\"))\n\tif !hasPermissionRule(workspaceCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"remembered rule missing from workspace config: %v\", workspaceCfg.Permissions.Allow)\n\t}\n}\n\nfunc TestRememberPermissionRuleEmptyRootUsesSourcePath(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\"))\n\n\tcwd := t.TempDir()\n\tt.Chdir(cwd)\n\tuserConfig := config.UserConfigPath()\n\twriteFile(t, filepath.Dir(userConfig), filepath.Base(userConfig), `\n[permissions]\nallow = [\"bash(user*)\"]\n`)\n\n\tconst rule = \"bash=go env\"\n\trememberPermissionRule(\"\", rule)\n\n\tuserCfg := config.LoadForEdit(userConfig)\n\tif !hasPermissionRule(userCfg.Permissions.Allow, rule) {\n\t\tt.Fatalf(\"empty root should remember into SourcePath config: %v\", userCfg.Permissions.Allow)\n\t}\n\tif _, err := os.Stat(filepath.Join(cwd, \"reasonix.toml\")); !os.IsNotExist(err) {\n\t\tt.Fatalf(\"empty root should not create cwd config when SourcePath exists, err=%v\", err)\n\t}\n}\n\nfunc hasPermissionRule(rules []string, want string) bool {\n\tfor _, rule := range rules {\n\t\tif rule == want {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := t.TempDir()\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(filepath.Join(home, \".env\")); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"~/.env missing migrated key: %q (err %v)\", data, err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := t.TempDir()\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), and a missing/unknown tier value falls into\n// lazy — the project default that keeps adding a plugin from ever slowing the\n// next launch.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty must default to lazy\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 1 || bg[0].Name != \"b1\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1]\", bg)\n\t}\n\t// Lazy holds both the explicit lazy entry and the default-fallback one, in\n\t// the order they appeared in the input — proves the empty-tier default\n\t// flows through partition without reshuffling.\n\tif len(lazy) != 2 || lazy[0].Name != \"l1\" || lazy[1].Name != \"default\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1, default] preserving input order\", lazy)\n\t}\n}\n\n// TestBuildEagerStartsAtBoot proves an eager-tier plugin actually completes\n// its handshake on the boot critical path: Host.ServerNames() must include the\n// plugin after Build returns. We point the plugin at this test binary running\n// as a stdio MCP helper (see TestHelperProcess), so the spawn is real but\n// deterministic and hermetic — no external MCP server required on PATH.\nfunc TestBuildEagerStartsAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"eagermock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"eager\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tnames := ctrl.Host().ServerNames()\n\tfound := false\n\tfor _, n := range names {\n\t\tif n == \"eagermock\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"eager plugin missing from Host.ServerNames() = %v — boot did not block on its handshake\", names)\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for a healthy eager plugin\", got)\n\t}\n}\n\n// TestBuildLazyDoesNotConnectAtBoot is the inverse of the eager test: a\n// lazy-tier plugin must NOT trigger a subprocess handshake during Build, so\n// the boot critical path stays empty even with a slow-to-spawn server in\n// config. We assert through Host.ServerNames() (must not list the plugin) and\n// Host.Failures() (lazy plugins never appear here — a failure surfaces only on\n// first model use, not at boot). The placeholder tool registration itself is\n// covered by internal/plugin/lazy_test.go's TestLazy* suite; the Registry has\n// no public accessor on Controller, so at this layer we pin the load-bearing\n// boot-time invariant — no spawn — rather than re-validating the placeholder.\nfunc TestBuildLazyDoesNotConnectAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"lazymock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"lazy\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"lazymock\" {\n\t\t\tt.Fatalf(\"lazy plugin %q appeared in Host.ServerNames() — boot connected it eagerly\", n)\n\t\t}\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — lazy plugins must not even attempt a boot-time spawn\", got)\n\t}\n\t// The configured plugin is still recognised as a configured-but-disconnected\n\t// server, the same signal /mcp uses to render its \"lazy, will connect on\n\t// first use\" line — proves the entry made it through partition into the\n\t// lazy bucket rather than being dropped.\n\tdconn := ctrl.DisconnectedMCPNames()\n\tfoundDisconnected := false\n\tfor _, n := range dconn {\n\t\tif n == \"lazymock\" {\n\t\t\tfoundDisconnected = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDisconnected {\n\t\tt.Fatalf(\"DisconnectedMCPNames() = %v, want it to include the lazy plugin (configured but not connected)\", dconn)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\n// TestBuildAutoDemoteFromStats proves the Phase 5 telemetry → Phase 4 tier\n// bridge: three consecutive over-budget startup samples must demote an\n// eager-tier plugin to lazy at the *next* boot, so the user pays for a slow\n// MCP server at most a handful of starts. We pre-seed three 30s samples for\n// \"slowserver\" (well above 2 * DefaultStartupBudget = 10s) via the public\n// RecordStartup API, then Build a config that declares \"slowserver\" eager and\n// verify (a) boot did NOT block on its handshake — the plugin is absent from\n// Host.ServerNames() — and (b) a Notice carrying the demote reason fired so\n// the user understands why their explicit \"eager\" was ignored this session.\nfunc TestBuildAutoDemoteFromStats(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"slowserver\" {\n\t\t\tt.Fatalf(\"demoted plugin %q still appeared in Host.ServerNames() — auto-demote did not move it out of the eager bucket\", n)\n\t\t}\n\t}\n\t// Crucially the missing-binary command never ran: a real eager attempt\n\t// would have surfaced as a Failure with a spawn error. An empty failures\n\t// list proves boot skipped the spawn entirely, not that it tried and\n\t// silently swallowed the error.\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — demoted plugin should never have been spawned\", got)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDemoteNotice {\n\t\tt.Fatalf(\"no demote notice surfaced; got %+v\", notices)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "7264bb78720174fdaf967cd7b08306cde37c2d9d": {"t": 1785750730.5978043, "n": 25574, "h": "f70ddbd4a043e330", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\n\t// Blank imports register the provider kind and built-in tools the same way\n\t// cmd/reasonix's main does; without them Build sees an empty provider\n\t// registry and a bare tool set.\n\t_ \"reasonix/internal/provider/openai\"\n\t_ \"reasonix/internal/tool/builtin\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildRecordsMCPStartupFailure(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"missing\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`)\n\tvar notices []event.Event\n\tctrl, err := Build(context.Background(), Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not fail when an MCP server is unavailable: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tfailures := ctrl.Host().Failures()\n\tif len(failures) != 1 || failures[0].Name != \"missing\" {\n\t\tt.Fatalf(\"failures = %+v, want missing\", failures)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"failed to start\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing startup warning notice: %+v\", notices)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := t.TempDir()\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(filepath.Join(home, \".env\")); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"~/.env missing migrated key: %q (err %v)\", data, err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := t.TempDir()\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), and a missing/unknown tier value falls into\n// lazy — the project default that keeps adding a plugin from ever slowing the\n// next launch.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty must default to lazy\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 1 || bg[0].Name != \"b1\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1]\", bg)\n\t}\n\t// Lazy holds both the explicit lazy entry and the default-fallback one, in\n\t// the order they appeared in the input — proves the empty-tier default\n\t// flows through partition without reshuffling.\n\tif len(lazy) != 2 || lazy[0].Name != \"l1\" || lazy[1].Name != \"default\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1, default] preserving input order\", lazy)\n\t}\n}\n\n// TestBuildEagerStartsAtBoot proves an eager-tier plugin actually completes\n// its handshake on the boot critical path: Host.ServerNames() must include the\n// plugin after Build returns. We point the plugin at this test binary running\n// as a stdio MCP helper (see TestHelperProcess), so the spawn is real but\n// deterministic and hermetic — no external MCP server required on PATH.\nfunc TestBuildEagerStartsAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"eagermock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"eager\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tnames := ctrl.Host().ServerNames()\n\tfound := false\n\tfor _, n := range names {\n\t\tif n == \"eagermock\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"eager plugin missing from Host.ServerNames() = %v — boot did not block on its handshake\", names)\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for a healthy eager plugin\", got)\n\t}\n}\n\n// TestBuildLazyDoesNotConnectAtBoot is the inverse of the eager test: a\n// lazy-tier plugin must NOT trigger a subprocess handshake during Build, so\n// the boot critical path stays empty even with a slow-to-spawn server in\n// config. We assert through Host.ServerNames() (must not list the plugin) and\n// Host.Failures() (lazy plugins never appear here — a failure surfaces only on\n// first model use, not at boot). The placeholder tool registration itself is\n// covered by internal/plugin/lazy_test.go's TestLazy* suite; the Registry has\n// no public accessor on Controller, so at this layer we pin the load-bearing\n// boot-time invariant — no spawn — rather than re-validating the placeholder.\nfunc TestBuildLazyDoesNotConnectAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"lazymock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"lazy\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"lazymock\" {\n\t\t\tt.Fatalf(\"lazy plugin %q appeared in Host.ServerNames() — boot connected it eagerly\", n)\n\t\t}\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — lazy plugins must not even attempt a boot-time spawn\", got)\n\t}\n\t// The configured plugin is still recognised as a configured-but-disconnected\n\t// server, the same signal /mcp uses to render its \"lazy, will connect on\n\t// first use\" line — proves the entry made it through partition into the\n\t// lazy bucket rather than being dropped.\n\tdconn := ctrl.DisconnectedMCPNames()\n\tfoundDisconnected := false\n\tfor _, n := range dconn {\n\t\tif n == \"lazymock\" {\n\t\t\tfoundDisconnected = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDisconnected {\n\t\tt.Fatalf(\"DisconnectedMCPNames() = %v, want it to include the lazy plugin (configured but not connected)\", dconn)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\ntier = \"background\"\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\n// TestBuildAutoDemoteFromStats proves the Phase 5 telemetry → Phase 4 tier\n// bridge: three consecutive over-budget startup samples must demote an\n// eager-tier plugin to lazy at the *next* boot, so the user pays for a slow\n// MCP server at most a handful of starts. We pre-seed three 30s samples for\n// \"slowserver\" (well above 2 * DefaultStartupBudget = 10s) via the public\n// RecordStartup API, then Build a config that declares \"slowserver\" eager and\n// verify (a) boot did NOT block on its handshake — the plugin is absent from\n// Host.ServerNames() — and (b) a Notice carrying the demote reason fired so\n// the user understands why their explicit \"eager\" was ignored this session.\nfunc TestBuildAutoDemoteFromStats(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"slowserver\" {\n\t\t\tt.Fatalf(\"demoted plugin %q still appeared in Host.ServerNames() — auto-demote did not move it out of the eager bucket\", n)\n\t\t}\n\t}\n\t// Crucially the missing-binary command never ran: a real eager attempt\n\t// would have surfaced as a Failure with a spawn error. An empty failures\n\t// list proves boot skipped the spawn entirely, not that it tried and\n\t// silently swallowed the error.\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — demoted plugin should never have been spawned\", got)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDemoteNotice {\n\t\tt.Fatalf(\"no demote notice surfaced; got %+v\", notices)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "576ae9c0c2b042468f344bfeabd371b842568a0a": {"t": 1785750731.819201, "n": 25554, "h": "e5e0c085b6f1b01a", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\n\t// Blank imports register the provider kind and built-in tools the same way\n\t// cmd/reasonix's main does; without them Build sees an empty provider\n\t// registry and a bare tool set.\n\t_ \"reasonix/internal/provider/openai\"\n\t_ \"reasonix/internal/tool/builtin\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildOmitsDisabledSkillsFromPromptAndRuntimeList(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[skills]\ndisabled_skills = [\"projskill\", \"review\"]\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, s := range ctrl.Skills() {\n\t\tif s.Name == \"projskill\" || s.Name == \"review\" {\n\t\t\tt.Fatalf(\"disabled skill %q should not be executable: %v\", s.Name, ctrl.Skills())\n\t\t}\n\t}\n\tvar allHasProj bool\n\tfor _, s := range ctrl.AllSkills() {\n\t\tif s.Name == \"projskill\" {\n\t\t\tallHasProj = true\n\t\t}\n\t}\n\tif !allHasProj {\n\t\tt.Fatalf(\"AllSkills should include disabled skills for management: %v\", ctrl.AllSkills())\n\t}\n\tsys := systemMessage(ctrl.History())\n\tif strings.Contains(sys, \"projskill\") || strings.Contains(sys, \"- review \") {\n\t\tt.Fatalf(\"disabled skill names should be omitted from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildRecordsMCPStartupFailure(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"missing\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`)\n\tvar notices []event.Event\n\tctrl, err := Build(context.Background(), Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not fail when an MCP server is unavailable: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tfailures := ctrl.Host().Failures()\n\tif len(failures) != 1 || failures[0].Name != \"missing\" {\n\t\tt.Fatalf(\"failures = %+v, want missing\", failures)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"failed to start\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing startup warning notice: %+v\", notices)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := t.TempDir()\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(filepath.Join(home, \".env\")); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"~/.env missing migrated key: %q (err %v)\", data, err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := t.TempDir()\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), and a missing/unknown tier value falls into\n// lazy — the project default that keeps adding a plugin from ever slowing the\n// next launch.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty must default to lazy\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 1 || bg[0].Name != \"b1\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1]\", bg)\n\t}\n\t// Lazy holds both the explicit lazy entry and the default-fallback one, in\n\t// the order they appeared in the input — proves the empty-tier default\n\t// flows through partition without reshuffling.\n\tif len(lazy) != 2 || lazy[0].Name != \"l1\" || lazy[1].Name != \"default\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1, default] preserving input order\", lazy)\n\t}\n}\n\n// TestBuildEagerStartsAtBoot proves an eager-tier plugin actually completes\n// its handshake on the boot critical path: Host.ServerNames() must include the\n// plugin after Build returns. We point the plugin at this test binary running\n// as a stdio MCP helper (see TestHelperProcess), so the spawn is real but\n// deterministic and hermetic — no external MCP server required on PATH.\nfunc TestBuildEagerStartsAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"eagermock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"eager\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tnames := ctrl.Host().ServerNames()\n\tfound := false\n\tfor _, n := range names {\n\t\tif n == \"eagermock\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"eager plugin missing from Host.ServerNames() = %v — boot did not block on its handshake\", names)\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for a healthy eager plugin\", got)\n\t}\n}\n\n// TestBuildLazyDoesNotConnectAtBoot is the inverse of the eager test: a\n// lazy-tier plugin must NOT trigger a subprocess handshake during Build, so\n// the boot critical path stays empty even with a slow-to-spawn server in\n// config. We assert through Host.ServerNames() (must not list the plugin) and\n// Host.Failures() (lazy plugins never appear here — a failure surfaces only on\n// first model use, not at boot). The placeholder tool registration itself is\n// covered by internal/plugin/lazy_test.go's TestLazy* suite; the Registry has\n// no public accessor on Controller, so at this layer we pin the load-bearing\n// boot-time invariant — no spawn — rather than re-validating the placeholder.\nfunc TestBuildLazyDoesNotConnectAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"lazymock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"lazy\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"lazymock\" {\n\t\t\tt.Fatalf(\"lazy plugin %q appeared in Host.ServerNames() — boot connected it eagerly\", n)\n\t\t}\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — lazy plugins must not even attempt a boot-time spawn\", got)\n\t}\n\t// The configured plugin is still recognised as a configured-but-disconnected\n\t// server, the same signal /mcp uses to render its \"lazy, will connect on\n\t// first use\" line — proves the entry made it through partition into the\n\t// lazy bucket rather than being dropped.\n\tdconn := ctrl.DisconnectedMCPNames()\n\tfoundDisconnected := false\n\tfor _, n := range dconn {\n\t\tif n == \"lazymock\" {\n\t\t\tfoundDisconnected = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDisconnected {\n\t\tt.Fatalf(\"DisconnectedMCPNames() = %v, want it to include the lazy plugin (configured but not connected)\", dconn)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\n// TestBuildAutoDemoteFromStats proves the Phase 5 telemetry → Phase 4 tier\n// bridge: three consecutive over-budget startup samples must demote an\n// eager-tier plugin to lazy at the *next* boot, so the user pays for a slow\n// MCP server at most a handful of starts. We pre-seed three 30s samples for\n// \"slowserver\" (well above 2 * DefaultStartupBudget = 10s) via the public\n// RecordStartup API, then Build a config that declares \"slowserver\" eager and\n// verify (a) boot did NOT block on its handshake — the plugin is absent from\n// Host.ServerNames() — and (b) a Notice carrying the demote reason fired so\n// the user understands why their explicit \"eager\" was ignored this session.\nfunc TestBuildAutoDemoteFromStats(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"slowserver\" {\n\t\t\tt.Fatalf(\"demoted plugin %q still appeared in Host.ServerNames() — auto-demote did not move it out of the eager bucket\", n)\n\t\t}\n\t}\n\t// Crucially the missing-binary command never ran: a real eager attempt\n\t// would have surfaced as a Failure with a spawn error. An empty failures\n\t// list proves boot skipped the spawn entirely, not that it tried and\n\t// silently swallowed the error.\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — demoted plugin should never have been spawned\", got)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDemoteNotice {\n\t\tt.Fatalf(\"no demote notice surfaced; got %+v\", notices)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "1ab14a9197c5a38e912a5ff00ae3969cf041490f": {"t": 1785750733.0837588, "n": 24177, "h": "9be0090ae8817452", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\n\t// Blank imports register the provider kind and built-in tools the same way\n\t// cmd/reasonix's main does; without them Build sees an empty provider\n\t// registry and a bare tool set.\n\t_ \"reasonix/internal/provider/openai\"\n\t_ \"reasonix/internal/tool/builtin\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildRecordsMCPStartupFailure(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"missing\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`)\n\tvar notices []event.Event\n\tctrl, err := Build(context.Background(), Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not fail when an MCP server is unavailable: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tfailures := ctrl.Host().Failures()\n\tif len(failures) != 1 || failures[0].Name != \"missing\" {\n\t\tt.Fatalf(\"failures = %+v, want missing\", failures)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"failed to start\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing startup warning notice: %+v\", notices)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := t.TempDir()\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(filepath.Join(home, \".env\")); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"~/.env missing migrated key: %q (err %v)\", data, err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := t.TempDir()\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), and a missing/unknown tier value falls into\n// lazy — the project default that keeps adding a plugin from ever slowing the\n// next launch.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty must default to lazy\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 1 || bg[0].Name != \"b1\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1]\", bg)\n\t}\n\t// Lazy holds both the explicit lazy entry and the default-fallback one, in\n\t// the order they appeared in the input — proves the empty-tier default\n\t// flows through partition without reshuffling.\n\tif len(lazy) != 2 || lazy[0].Name != \"l1\" || lazy[1].Name != \"default\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1, default] preserving input order\", lazy)\n\t}\n}\n\n// TestBuildEagerStartsAtBoot proves an eager-tier plugin actually completes\n// its handshake on the boot critical path: Host.ServerNames() must include the\n// plugin after Build returns. We point the plugin at this test binary running\n// as a stdio MCP helper (see TestHelperProcess), so the spawn is real but\n// deterministic and hermetic — no external MCP server required on PATH.\nfunc TestBuildEagerStartsAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"eagermock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"eager\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tnames := ctrl.Host().ServerNames()\n\tfound := false\n\tfor _, n := range names {\n\t\tif n == \"eagermock\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"eager plugin missing from Host.ServerNames() = %v — boot did not block on its handshake\", names)\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for a healthy eager plugin\", got)\n\t}\n}\n\n// TestBuildLazyDoesNotConnectAtBoot is the inverse of the eager test: a\n// lazy-tier plugin must NOT trigger a subprocess handshake during Build, so\n// the boot critical path stays empty even with a slow-to-spawn server in\n// config. We assert through Host.ServerNames() (must not list the plugin) and\n// Host.Failures() (lazy plugins never appear here — a failure surfaces only on\n// first model use, not at boot). The placeholder tool registration itself is\n// covered by internal/plugin/lazy_test.go's TestLazy* suite; the Registry has\n// no public accessor on Controller, so at this layer we pin the load-bearing\n// boot-time invariant — no spawn — rather than re-validating the placeholder.\nfunc TestBuildLazyDoesNotConnectAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"lazymock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"lazy\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"lazymock\" {\n\t\t\tt.Fatalf(\"lazy plugin %q appeared in Host.ServerNames() — boot connected it eagerly\", n)\n\t\t}\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — lazy plugins must not even attempt a boot-time spawn\", got)\n\t}\n\t// The configured plugin is still recognised as a configured-but-disconnected\n\t// server, the same signal /mcp uses to render its \"lazy, will connect on\n\t// first use\" line — proves the entry made it through partition into the\n\t// lazy bucket rather than being dropped.\n\tdconn := ctrl.DisconnectedMCPNames()\n\tfoundDisconnected := false\n\tfor _, n := range dconn {\n\t\tif n == \"lazymock\" {\n\t\t\tfoundDisconnected = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDisconnected {\n\t\tt.Fatalf(\"DisconnectedMCPNames() = %v, want it to include the lazy plugin (configured but not connected)\", dconn)\n\t}\n}\n\nfunc TestBuildColdCodegraphStartsInBackground(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\tlauncher := writeCodegraphHelper(t, dir)\n\tt.Setenv(\"GO_WANT_HELPER_PROCESS\", \"1\")\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = true\npath = %q\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`, launcher))\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for cold built-in codegraph background startup\", got)\n\t}\n\tcodegraphDir := filepath.Join(dir, \".codegraph\")\n\tdeadline := time.Now().Add(5 * time.Second)\n\tfor {\n\t\tif _, err := os.Stat(codegraphDir); err == nil {\n\t\t\tbreak\n\t\t} else if time.Now().After(deadline) {\n\t\t\tt.Fatalf(\"cold codegraph init did not create .codegraph/: %v\", err)\n\t\t}\n\t\ttime.Sleep(10 * time.Millisecond)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"preparing code-intelligence tools in the background\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing background warmup notice; got %+v\", notices)\n\t}\n}\n\n// TestBuildAutoDemoteFromStats proves the Phase 5 telemetry → Phase 4 tier\n// bridge: three consecutive over-budget startup samples must demote an\n// eager-tier plugin to lazy at the *next* boot, so the user pays for a slow\n// MCP server at most a handful of starts. We pre-seed three 30s samples for\n// \"slowserver\" (well above 2 * DefaultStartupBudget = 10s) via the public\n// RecordStartup API, then Build a config that declares \"slowserver\" eager and\n// verify (a) boot did NOT block on its handshake — the plugin is absent from\n// Host.ServerNames() — and (b) a Notice carrying the demote reason fired so\n// the user understands why their explicit \"eager\" was ignored this session.\nfunc TestBuildAutoDemoteFromStats(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"slowserver\" {\n\t\t\tt.Fatalf(\"demoted plugin %q still appeared in Host.ServerNames() — auto-demote did not move it out of the eager bucket\", n)\n\t\t}\n\t}\n\t// Crucially the missing-binary command never ran: a real eager attempt\n\t// would have surfaced as a Failure with a spawn error. An empty failures\n\t// list proves boot skipped the spawn entirely, not that it tried and\n\t// silently swallowed the error.\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — demoted plugin should never have been spawned\", got)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDemoteNotice {\n\t\tt.Fatalf(\"no demote notice surfaced; got %+v\", notices)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n\nfunc writeCodegraphHelper(t *testing.T, dir string) string {\n\tt.Helper()\n\tpath := filepath.Join(dir, \"codegraph-helper\")\n\tif runtime.GOOS == \"windows\" {\n\t\tpath += \".exe\"\n\t}\n\tsrc := filepath.Join(dir, \"codegraph-helper.go\")\n\tif err := os.WriteFile(src, []byte(`package main\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"os\"\n\t\"path/filepath\"\n)\n\nfunc main() {\n\tif len(os.Args) >= 3 && os.Args[1] == \"init\" {\n\t\t_ = os.MkdirAll(filepath.Join(os.Args[2], \".codegraph\"), 0o755)\n\t\treturn\n\t}\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `+\"`json:\\\"id\\\"`\"+`\n\t\t\tMethod string `+\"`json:\\\"method\\\"`\"+`\n\t\t\tParams json.RawMessage `+\"`json:\\\"params\\\"`\"+`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil || req.ID == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"codegraph\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"search\",\n\t\t\t\t\"description\": \"Search symbols.\",\n\t\t\t\t\"inputSchema\": map[string]any{\"type\": \"object\"},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\t_, _ = os.Stdout.Write(append(b, '\\n'))\n\t}\n}\n`), 0o644); err != nil {\n\t\tt.Fatal(err)\n\t}\n\tcmd := exec.Command(\"go\", \"build\", \"-o\", path, src)\n\tif out, err := cmd.CombinedOutput(); err != nil {\n\t\tt.Fatalf(\"build codegraph helper: %v\\n%s\", err, out)\n\t}\n\treturn path\n}\n"}, "f63b3e7665417dc1461ff493049858998bf77c1a": {"t": 1785750734.3092065, "n": 20822, "h": "642077996964db23", "c": "package boot\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/event\"\n\t\"reasonix/internal/plugin\"\n\t\"reasonix/internal/provider\"\n\n\t// Blank imports register the provider kind and built-in tools the same way\n\t// cmd/reasonix's main does; without them Build sees an empty provider\n\t// registry and a bare tool set.\n\t_ \"reasonix/internal/provider/openai\"\n\t_ \"reasonix/internal/tool/builtin\"\n)\n\n// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the\n// cache-first wiring: a project REASONIX.md is discovered at boot and folded\n// into the session's system message (the cached prefix), and the `remember`\n// tool is registered. It builds a real Controller from a throwaway project dir.\nfunc TestBuildFoldsProjectMemoryIntoSystemPrompt(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE SYSTEM PROMPT\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \"REASONIX.md\", \"Project rule: always run go vet before committing.\")\n\n\tctrl, err := Build(context.Background(), Options{}) // RequireKey false: no network/key needed\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\t// The system message is the cached prefix; it must contain both the base\n\t// prompt and the discovered memory.\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"BASE SYSTEM PROMPT\") {\n\t\tt.Fatalf(\"base prompt missing from system message:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"always run go vet before committing\") {\n\t\tt.Fatalf(\"project REASONIX.md not folded into system message:\\n%s\", sys)\n\t}\n\t// Base must come first so it stays a valid cache prefix when memory changes.\n\tif strings.Index(sys, \"BASE SYSTEM PROMPT\") > strings.Index(sys, \"always run go vet\") {\n\t\tt.Fatalf(\"memory should follow the base prompt, not precede it:\\n%s\", sys)\n\t}\n\n\tif mem := ctrl.Memory(); mem == nil || len(mem.Docs) == 0 {\n\t\tt.Fatal(\"controller memory set is empty after discovering REASONIX.md\")\n\t}\n}\n\n// TestBuildDiscoversSkills proves the skill wiring end-to-end: a project skill\n// is discovered at boot, surfaced via Controller.Skills(), and its name folds\n// into the cache-stable system prompt's \"# Skills\" index alongside a built-in.\nfunc TestBuildDiscoversSkills(t *testing.T) {\n\tdir := t.TempDir()\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\"))\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\twriteFile(t, dir, \".reasonix/skills/projskill.md\", \"---\\ndescription: a project skill\\n---\\nplaybook\")\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tvar hasProj, hasBuiltin bool\n\tfor _, s := range ctrl.Skills() {\n\t\tswitch s.Name {\n\t\tcase \"projskill\":\n\t\t\thasProj = true\n\t\tcase \"explore\":\n\t\t\thasBuiltin = true\n\t\t}\n\t}\n\tif !hasProj || !hasBuiltin {\n\t\tt.Fatalf(\"Skills() should include the project skill and a built-in; got %v\", ctrl.Skills())\n\t}\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, \"# Skills\") {\n\t\tt.Fatalf(\"skills index missing from system prompt:\\n%s\", sys)\n\t}\n\tif !strings.Contains(sys, \"projskill\") || !strings.Contains(sys, \"explore\") {\n\t\tt.Fatalf(\"skill names missing from index:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildRecordsMCPStartupFailure(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"missing\"\ncommand = \"reasonix-missing-mcp-binary\"\ntier = \"eager\"\n`)\n\tvar notices []event.Event\n\tctrl, err := Build(context.Background(), Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build should not fail when an MCP server is unavailable: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\tfailures := ctrl.Host().Failures()\n\tif len(failures) != 1 || failures[0].Name != \"missing\" {\n\t\tt.Fatalf(\"failures = %+v, want missing\", failures)\n\t}\n\tfoundNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"failed to start\") {\n\t\t\tfoundNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundNotice {\n\t\tt.Fatalf(\"missing startup warning notice: %+v\", notices)\n\t}\n}\n\n// TestBuildWithoutMemoryLeavesPromptUnchanged is the inverse invariant: with no\n// memory files, the system prompt is exactly the configured base — the cache\n// prefix is untouched by the memory feature.\nfunc TestBuildWithoutMemoryLeavesPromptUnchanged(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"JUST THE BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\t// The built-in skills always append a \"# Skills\" index to the prefix; this\n\t// test is about memory, so strip that and assert the remaining base is exactly\n\t// the configured prompt — i.e. no *project/ancestor* memory leaked in. (A\n\t// user-global REASONIX.md in the real config dir could append; the test\n\t// environment has none, so the base stands alone.)\n\tbase := sys\n\tif i := strings.Index(sys, \"\\n\\n# Skills\"); i >= 0 {\n\t\tbase = sys[:i]\n\t}\n\t// The language policy is always appended at boot; strip it so this assertion\n\t// is purely about whether project/ancestor memory leaked into the base.\n\tbase = stripLanguagePolicy(base)\n\tif base != \"JUST THE BASE\" {\n\t\tt.Fatalf(\"expected untouched base prompt, got:\\n%s\", sys)\n\t}\n}\n\nfunc TestBuildLanguagePolicyIsAppended(t *testing.T) {\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n`)\n\n\tctrl, err := Build(context.Background(), Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tsys := systemMessage(ctrl.History())\n\tif !strings.Contains(sys, config.LanguagePolicy) {\n\t\tt.Fatalf(\"language policy missing from system prompt:\\n%s\", sys)\n\t}\n}\n\nfunc systemMessage(msgs []provider.Message) string {\n\tfor _, m := range msgs {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc stripLanguagePolicy(s string) string {\n\ts = strings.TrimSpace(s)\n\tfor _, policy := range []string{\n\t\tconfig.LanguagePolicy,\n\t} {\n\t\ts = strings.TrimSpace(strings.TrimSuffix(s, policy))\n\t}\n\treturn s\n}\n\nfunc writeFile(t *testing.T, dir, name, body string) {\n\tt.Helper()\n\tif err := writeFileRaw(dir, name, body); err != nil {\n\t\tt.Fatal(err)\n\t}\n}\n\n// TestBuildMigratesLegacyConfigEndToEnd drives the real boot path: a v0.x\n// ~/.reasonix/config.json with no v1+ config present must be imported during\n// Build — config written, key pinned into the env, and the user told via a notice.\nfunc TestBuildMigratesLegacyConfigEndToEnd(t *testing.T) {\n\thome := t.TempDir()\n\tt.Setenv(\"HOME\", home)\n\tt.Setenv(\"USERPROFILE\", home) // os.UserHomeDir on Windows\n\tt.Setenv(\"XDG_CONFIG_HOME\", filepath.Join(home, \".config\")) // os.UserConfigDir on Linux\n\tt.Setenv(\"AppData\", filepath.Join(home, \"AppData\")) // os.UserConfigDir on Windows\n\tt.Setenv(\"DEEPSEEK_API_KEY\", \"\") // track for cleanup; migration os.Setenv's it live\n\n\tproj := t.TempDir()\n\tt.Chdir(proj)\n\t// codegraph off keeps Build offline; it merges over the migrated user config\n\t// without dropping the migrated plugins.\n\twriteFile(t, proj, \"reasonix.toml\", \"[codegraph]\\nenabled = false\\n\")\n\twriteFile(t, filepath.Join(home, \".reasonix\"), \"config.json\",\n\t\t`{\"apiKey\":\"sk-e2e\",\"lang\":\"zh\",\"mcpServers\":{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"server-fs\"]}}}`)\n\twriteFile(t, filepath.Join(home, \".reasonix\", \"sessions\"), \"chat-1.events.jsonl\",\n\t\t`{\"type\":\"user.message\",\"id\":1,\"ts\":\"t\",\"turn\":0,\"text\":\"hello from v0.x\"}`+\"\\n\"+\n\t\t\t`{\"type\":\"model.final\",\"id\":2,\"ts\":\"t\",\"turn\":0,\"content\":\"hi\",\"toolCalls\":[],\"usage\":{},\"costUsd\":0}`+\"\\n\")\n\n\tvar notices []string\n\tsink := event.FuncSink(func(e event.Event) {\n\t\tif e.Kind == event.Notice {\n\t\t\tnotices = append(notices, e.Text)\n\t\t}\n\t})\n\n\tctrl, err := Build(context.Background(), Options{Sink: sink})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tmigrated := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"migrated your previous configuration\") {\n\t\t\tmigrated = true\n\t\t}\n\t}\n\tif !migrated {\n\t\tt.Fatalf(\"no migration notice emitted; got %v\", notices)\n\t}\n\n\tdest := config.UserConfigPath()\n\tdata, err := os.ReadFile(dest)\n\tif err != nil {\n\t\tt.Fatalf(\"v2 config not written to %s: %v\", dest, err)\n\t}\n\tif !strings.Contains(string(data), `name = \"fs\"`) || !strings.Contains(string(data), `language = \"zh\"`) {\n\t\tt.Errorf(\"migrated config missing plugin/lang:\\n%s\", data)\n\t}\n\n\tif got := os.Getenv(\"DEEPSEEK_API_KEY\"); got != \"sk-e2e\" {\n\t\tt.Errorf(\"DEEPSEEK_API_KEY not pinned into env after migration: %q\", got)\n\t}\n\n\tif data, err := os.ReadFile(filepath.Join(home, \".env\")); err != nil || !strings.Contains(string(data), \"DEEPSEEK_API_KEY=sk-e2e\") {\n\t\tt.Errorf(\"~/.env missing migrated key: %q (err %v)\", data, err)\n\t}\n\n\tsessionImported := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n, \"imported\") && strings.Contains(n, \"past session\") {\n\t\t\tsessionImported = true\n\t\t}\n\t}\n\tif !sessionImported {\n\t\tt.Errorf(\"no session-import notice emitted; got %v\", notices)\n\t}\n\tmigratedSession := filepath.Join(config.SessionDir(), \"chat-1.jsonl\")\n\tif _, err := os.Stat(migratedSession); err != nil {\n\t\tt.Errorf(\"legacy session not imported to %s: %v\", migratedSession, err)\n\t}\n}\n\n// isolateConfigHome redirects os.UserConfigDir() (and the cache subtree under\n// it) at a per-test temp dir by overriding the env vars Go's stdlib reads —\n// HOME on darwin, XDG_CONFIG_HOME on linux. Without this, Build's plugin path\n// would persist startup stats and cached schemas into the developer's real\n// ~/Library/Application Support tree and bleed state across tests. Mirrors the\n// withTempCache helper in internal/plugin/stats_test.go.\nfunc isolateConfigHome(t *testing.T) string {\n\tt.Helper()\n\tdir := t.TempDir()\n\tt.Setenv(\"HOME\", dir)\n\tt.Setenv(\"XDG_CONFIG_HOME\", dir)\n\treturn dir\n}\n\n// TestPartitionByTier pins the bucket assignment contract that the rest of\n// boot.go's plugin orchestration depends on: each tier string maps to its own\n// slice, the original order inside a tier is preserved (so /mcp status and\n// stats land deterministically), and a missing/unknown tier value falls into\n// lazy — the project default that keeps adding a plugin from ever slowing the\n// next launch.\nfunc TestPartitionByTier(t *testing.T) {\n\tentries := []config.PluginEntry{\n\t\t{Name: \"e1\", Tier: \"eager\"},\n\t\t{Name: \"l1\", Tier: \"lazy\"},\n\t\t{Name: \"b1\", Tier: \"background\"},\n\t\t{Name: \"default\", Tier: \"\"}, // empty must default to lazy\n\t}\n\n\teager, lazy, bg := partitionByTier(entries)\n\n\tif len(eager) != 1 || eager[0].Name != \"e1\" {\n\t\tt.Fatalf(\"eager bucket = %+v, want [e1]\", eager)\n\t}\n\tif len(bg) != 1 || bg[0].Name != \"b1\" {\n\t\tt.Fatalf(\"background bucket = %+v, want [b1]\", bg)\n\t}\n\t// Lazy holds both the explicit lazy entry and the default-fallback one, in\n\t// the order they appeared in the input — proves the empty-tier default\n\t// flows through partition without reshuffling.\n\tif len(lazy) != 2 || lazy[0].Name != \"l1\" || lazy[1].Name != \"default\" {\n\t\tt.Fatalf(\"lazy bucket = %+v, want [l1, default] preserving input order\", lazy)\n\t}\n}\n\n// TestBuildEagerStartsAtBoot proves an eager-tier plugin actually completes\n// its handshake on the boot critical path: Host.ServerNames() must include the\n// plugin after Build returns. We point the plugin at this test binary running\n// as a stdio MCP helper (see TestHelperProcess), so the spawn is real but\n// deterministic and hermetic — no external MCP server required on PATH.\nfunc TestBuildEagerStartsAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"eagermock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"eager\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tnames := ctrl.Host().ServerNames()\n\tfound := false\n\tfor _, n := range names {\n\t\tif n == \"eagermock\" {\n\t\t\tfound = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatalf(\"eager plugin missing from Host.ServerNames() = %v — boot did not block on its handshake\", names)\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty for a healthy eager plugin\", got)\n\t}\n}\n\n// TestBuildLazyDoesNotConnectAtBoot is the inverse of the eager test: a\n// lazy-tier plugin must NOT trigger a subprocess handshake during Build, so\n// the boot critical path stays empty even with a slow-to-spawn server in\n// config. We assert through Host.ServerNames() (must not list the plugin) and\n// Host.Failures() (lazy plugins never appear here — a failure surfaces only on\n// first model use, not at boot). The placeholder tool registration itself is\n// covered by internal/plugin/lazy_test.go's TestLazy* suite; the Registry has\n// no public accessor on Controller, so at this layer we pin the load-bearing\n// boot-time invariant — no spawn — rather than re-validating the placeholder.\nfunc TestBuildLazyDoesNotConnectAtBoot(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\twriteFile(t, dir, \"reasonix.toml\", fmt.Sprintf(`\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"lazymock\"\ncommand = %q\nargs = [\"-test.run=TestHelperProcess\", \"--\"]\ntier = \"lazy\"\nenv = { GO_WANT_HELPER_PROCESS = \"1\" }\n`, os.Args[0]))\n\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"lazymock\" {\n\t\t\tt.Fatalf(\"lazy plugin %q appeared in Host.ServerNames() — boot connected it eagerly\", n)\n\t\t}\n\t}\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — lazy plugins must not even attempt a boot-time spawn\", got)\n\t}\n\t// The configured plugin is still recognised as a configured-but-disconnected\n\t// server, the same signal /mcp uses to render its \"lazy, will connect on\n\t// first use\" line — proves the entry made it through partition into the\n\t// lazy bucket rather than being dropped.\n\tdconn := ctrl.DisconnectedMCPNames()\n\tfoundDisconnected := false\n\tfor _, n := range dconn {\n\t\tif n == \"lazymock\" {\n\t\t\tfoundDisconnected = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDisconnected {\n\t\tt.Fatalf(\"DisconnectedMCPNames() = %v, want it to include the lazy plugin (configured but not connected)\", dconn)\n\t}\n}\n\n// TestBuildAutoDemoteFromStats proves the Phase 5 telemetry → Phase 4 tier\n// bridge: three consecutive over-budget startup samples must demote an\n// eager-tier plugin to lazy at the *next* boot, so the user pays for a slow\n// MCP server at most a handful of starts. We pre-seed three 30s samples for\n// \"slowserver\" (well above 2 * DefaultStartupBudget = 10s) via the public\n// RecordStartup API, then Build a config that declares \"slowserver\" eager and\n// verify (a) boot did NOT block on its handshake — the plugin is absent from\n// Host.ServerNames() — and (b) a Notice carrying the demote reason fired so\n// the user understands why their explicit \"eager\" was ignored this session.\nfunc TestBuildAutoDemoteFromStats(t *testing.T) {\n\tisolateConfigHome(t)\n\tdir := t.TempDir()\n\tt.Chdir(dir)\n\n\t// Three samples above 2*budget — the rule in stats.go's Recommend triggers\n\t// when the trailing window is entirely over the threshold. Use 30s so even\n\t// future budget bumps stay below the threshold.\n\tfor i := 0; i < 3; i++ {\n\t\tif err := plugin.RecordStartup(\"slowserver\", 30*time.Second); err != nil {\n\t\t\tt.Fatalf(\"RecordStartup #%d: %v\", i, err)\n\t\t}\n\t}\n\n\twriteFile(t, dir, \"reasonix.toml\", `\ndefault_model = \"test-model\"\n\n[codegraph]\nenabled = false\n\n[agent]\nsystem_prompt = \"BASE\"\n\n[[providers]]\nname = \"test-model\"\nkind = \"openai\"\nbase_url = \"https://example.invalid\"\nmodel = \"x\"\napi_key_env = \"REASONIX_TEST_KEY_UNSET\"\n\n[[plugins]]\nname = \"slowserver\"\ncommand = \"reasonix-missing-slow-mcp-binary\"\ntier = \"eager\"\n`)\n\n\tvar notices []event.Event\n\tctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)\n\tdefer cancel()\n\tctrl, err := Build(ctx, Options{\n\t\tSink: event.FuncSink(func(e event.Event) {\n\t\t\tif e.Kind == event.Notice {\n\t\t\t\tnotices = append(notices, e)\n\t\t\t}\n\t\t}),\n\t})\n\tif err != nil {\n\t\tt.Fatalf(\"Build: %v\", err)\n\t}\n\tdefer ctrl.Close()\n\n\tfor _, n := range ctrl.Host().ServerNames() {\n\t\tif n == \"slowserver\" {\n\t\t\tt.Fatalf(\"demoted plugin %q still appeared in Host.ServerNames() — auto-demote did not move it out of the eager bucket\", n)\n\t\t}\n\t}\n\t// Crucially the missing-binary command never ran: a real eager attempt\n\t// would have surfaced as a Failure with a spawn error. An empty failures\n\t// list proves boot skipped the spawn entirely, not that it tried and\n\t// silently swallowed the error.\n\tif got := ctrl.Host().Failures(); len(got) != 0 {\n\t\tt.Fatalf(\"Host.Failures() = %+v, want empty — demoted plugin should never have been spawned\", got)\n\t}\n\n\tfoundDemoteNotice := false\n\tfor _, n := range notices {\n\t\tif strings.Contains(n.Text, \"demoting to lazy\") {\n\t\t\tfoundDemoteNotice = true\n\t\t\tbreak\n\t\t}\n\t}\n\tif !foundDemoteNotice {\n\t\tt.Fatalf(\"no demote notice surfaced; got %+v\", notices)\n\t}\n}\n\n// TestHelperProcess is invoked as a subprocess by TestBuildEagerStartsAtBoot\n// and TestBuildLazyDoesNotConnectAtBoot. It mirrors the minimal MCP stdio\n// server in internal/plugin/plugin_test.go so the boot package can drive an\n// end-to-end handshake without depending on the plugin package's test helper\n// (Go's testing framework only re-invokes the binary of the test package\n// currently running). The helper gates on GO_WANT_HELPER_PROCESS=1 so a\n// normal `go test ./internal/boot/...` does not trip it.\nfunc TestHelperProcess(t *testing.T) {\n\tif os.Getenv(\"GO_WANT_HELPER_PROCESS\") != \"1\" {\n\t\treturn\n\t}\n\tdefer os.Exit(0)\n\n\tin := bufio.NewReader(os.Stdin)\n\tfor {\n\t\tline, err := in.ReadBytes('\\n')\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\tline = bytes.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tvar req struct {\n\t\t\tID *int `json:\"id\"`\n\t\t\tMethod string `json:\"method\"`\n\t\t\tParams json.RawMessage `json:\"params\"`\n\t\t}\n\t\tif err := json.Unmarshal(line, &req); err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tif req.ID == nil {\n\t\t\tcontinue // notification: no response\n\t\t}\n\n\t\tvar result any\n\t\tswitch req.Method {\n\t\tcase \"initialize\":\n\t\t\tresult = map[string]any{\n\t\t\t\t\"protocolVersion\": \"2024-11-05\",\n\t\t\t\t\"serverInfo\": map[string]any{\"name\": \"mock\", \"version\": \"0\"},\n\t\t\t\t\"capabilities\": map[string]any{},\n\t\t\t}\n\t\tcase \"tools/list\":\n\t\t\tresult = map[string]any{\"tools\": []map[string]any{{\n\t\t\t\t\"name\": \"echo\",\n\t\t\t\t\"description\": \"Echo back the message.\",\n\t\t\t\t\"inputSchema\": map[string]any{\n\t\t\t\t\t\"type\": \"object\",\n\t\t\t\t\t\"properties\": map[string]any{\"msg\": map[string]any{\"type\": \"string\"}},\n\t\t\t\t\t\"required\": []string{\"msg\"},\n\t\t\t\t},\n\t\t\t}}}\n\t\t}\n\n\t\tresp := map[string]any{\"jsonrpc\": \"2.0\", \"id\": *req.ID, \"result\": result}\n\t\tb, _ := json.Marshal(resp)\n\t\tos.Stdout.Write(append(b, '\\n'))\n\t}\n}\n"}}, "internal/config/effort_test.go": {"c480afd7570fbb1fb96ff82a0ab117c5df7768e8": {"t": 1785746067.7842135, "n": 4170, "h": "aeb81fcd644a52de", "c": "package config\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestIsMiniMaxEntry(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tbaseURL string\n\t\twant bool\n\t}{\n\t\t{\"https://api.minimaxi.com/v1\", true},\n\t\t{\"https://api.minimaxi.com\", true},\n\t\t{\"https://api.minimaxi.com/anthropic\", true},\n\t\t// Subdomain variants of the canonical host.\n\t\t{\"https://eu.minimaxi.com/v1\", true},\n\t\t{\"https://us.minimaxi.com/v1\", true},\n\t\t// Bare apex (no api. prefix) is rejected: it would only match if\n\t\t// the user pointed their base_url at the apex domain, which is a\n\t\t// misconfiguration — not a path we want to silently accept.\n\t\t{\"https://minimaxi.com/v1\", false},\n\t\t{\"https://minimaxi.com\", false},\n\t\t// Other vendors must not match.\n\t\t{\"https://api.deepseek.com\", false},\n\t\t{\"https://api.xiaomimimo.com/v1\", false},\n\t\t{\"https://api.minimax.example.com\", false}, // wrong spelling — must not match\n\t\t{\"\", false},\n\t} {\n\t\te := &ProviderEntry{Kind: \"openai\", BaseURL: tc.baseURL}\n\t\tif got := isMiniMaxEntry(e); got != tc.want {\n\t\t\tt.Errorf(\"baseURL=%q: isMiniMaxEntry=%v, want %v\", tc.baseURL, got, tc.want)\n\t\t}\n\t}\n}\n\nfunc TestEffortCapabilityMiniMax(t *testing.T) {\n\te := &ProviderEntry{Kind: \"openai\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\"}\n\tcap := EffortCapabilityForEntry(e)\n\tif !cap.Supported {\n\t\tt.Fatalf(\"M3 entry should expose /effort, got %+v\", cap)\n\t}\n\twantLevels := []string{\"auto\", \"adaptive\", \"disabled\"}\n\tif len(cap.Levels) != len(wantLevels) {\n\t\tt.Fatalf(\"levels = %v, want %v\", cap.Levels, wantLevels)\n\t}\n\tfor i, l := range wantLevels {\n\t\tif cap.Levels[i] != l {\n\t\t\tt.Errorf(\"levels[%d] = %q, want %q\", i, cap.Levels[i], l)\n\t\t}\n\t}\n\tif cap.Default != \"adaptive\" {\n\t\tt.Errorf(\"default = %q, want adaptive (M3 ships with thinking on)\", cap.Default)\n\t}\n}\n\nfunc TestNormalizeEffortMiniMax(t *testing.T) {\n\te := &ProviderEntry{Kind: \"openai\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\"}\n\tcases := []struct {\n\t\tin, want string\n\t}{\n\t\t{\"auto\", \"\"}, // auto == \"leave to provider default\" == empty\n\t\t{\"adaptive\", \"adaptive\"},\n\t\t{\"disabled\", \"disabled\"},\n\t\t{\"ADAPTIVE\", \"adaptive\"}, // case-insensitive\n\t\t// Stale values from other vendors still resolve to a valid M3 level\n\t\t// rather than failing the /effort command.\n\t\t{\"off\", \"disabled\"}, // retired DeepSeek \"no thinking\" → M3 actually supports \"disabled\"\n\t\t{\"low\", \"adaptive\"},\n\t\t{\"medium\", \"adaptive\"},\n\t\t{\"high\", \"adaptive\"},\n\t\t{\"xhigh\", \"disabled\"},\n\t\t{\"max\", \"disabled\"},\n\t}\n\tfor _, tc := range cases {\n\t\tgot, err := NormalizeEffort(e, tc.in)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"NormalizeEffort(%q) returned error: %v\", tc.in, err)\n\t\t\tcontinue\n\t\t}\n\t\tif got != tc.want {\n\t\t\tt.Errorf(\"NormalizeEffort(%q) = %q, want %q\", tc.in, got, tc.want)\n\t\t}\n\t}\n}\n\nfunc TestNormalizeEffortMiniMaxRejectsGarbage(t *testing.T) {\n\te := &ProviderEntry{Kind: \"openai\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\"}\n\t// \"turbo\" and similar unrecognised inputs reach the MiniMax switch and\n\t// are rejected with the MiniMax-specific usage hint. Empty input is\n\t// rejected earlier with the generic hint; both are valid user-facing\n\t// errors. \"off\" is *not* in this list — it's a retired level we now\n\t// migrate to \"adaptive\" (tested in TestNormalizeEffortMiniMax above).\n\tcases := map[string]string{\n\t\t\"turbo\": \"auto|adaptive|disabled\",\n\t\t\"\": \"auto|\",\n\t}\n\tfor in, wantHint := range cases {\n\t\t_, err := NormalizeEffort(e, in)\n\t\tif err == nil {\n\t\t\tt.Errorf(\"NormalizeEffort(%q) should be rejected\", in)\n\t\t\tcontinue\n\t\t}\n\t\tif !strings.Contains(err.Error(), wantHint) {\n\t\t\tt.Errorf(\"NormalizeEffort(%q) error = %q, want it to mention %q\", in, err.Error(), wantHint)\n\t\t}\n\t}\n}\n\nfunc TestEffectiveEffortMiniMax(t *testing.T) {\n\te := &ProviderEntry{Kind: \"openai\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\"}\n\t// unset → EffectiveEffort stays empty so the wire layer defaults to adaptive\n\tif got := EffectiveEffort(e); got != \"\" {\n\t\tt.Errorf(\"unset EffectiveEffort = %q, want \\\"\\\"\", got)\n\t}\n\t// explicit value is preserved verbatim\n\te.Effort = \"disabled\"\n\tif got := EffectiveEffort(e); got != \"disabled\" {\n\t\tt.Errorf(\"explicit EffectiveEffort = %q, want disabled\", got)\n\t}\n}\n"}, "8b2028e82427dc4925bc61c793c639116a44e8ee": {"t": 1785750693.569503, "n": 4170, "h": "aeb81fcd644a52de", "c": "package config\n\nimport (\n\t\"strings\"\n\t\"testing\"\n)\n\nfunc TestIsMiniMaxEntry(t *testing.T) {\n\tfor _, tc := range []struct {\n\t\tbaseURL string\n\t\twant bool\n\t}{\n\t\t{\"https://api.minimaxi.com/v1\", true},\n\t\t{\"https://api.minimaxi.com\", true},\n\t\t{\"https://api.minimaxi.com/anthropic\", true},\n\t\t// Subdomain variants of the canonical host.\n\t\t{\"https://eu.minimaxi.com/v1\", true},\n\t\t{\"https://us.minimaxi.com/v1\", true},\n\t\t// Bare apex (no api. prefix) is rejected: it would only match if\n\t\t// the user pointed their base_url at the apex domain, which is a\n\t\t// misconfiguration — not a path we want to silently accept.\n\t\t{\"https://minimaxi.com/v1\", false},\n\t\t{\"https://minimaxi.com\", false},\n\t\t// Other vendors must not match.\n\t\t{\"https://api.deepseek.com\", false},\n\t\t{\"https://api.xiaomimimo.com/v1\", false},\n\t\t{\"https://api.minimax.example.com\", false}, // wrong spelling — must not match\n\t\t{\"\", false},\n\t} {\n\t\te := &ProviderEntry{Kind: \"openai\", BaseURL: tc.baseURL}\n\t\tif got := isMiniMaxEntry(e); got != tc.want {\n\t\t\tt.Errorf(\"baseURL=%q: isMiniMaxEntry=%v, want %v\", tc.baseURL, got, tc.want)\n\t\t}\n\t}\n}\n\nfunc TestEffortCapabilityMiniMax(t *testing.T) {\n\te := &ProviderEntry{Kind: \"openai\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\"}\n\tcap := EffortCapabilityForEntry(e)\n\tif !cap.Supported {\n\t\tt.Fatalf(\"M3 entry should expose /effort, got %+v\", cap)\n\t}\n\twantLevels := []string{\"auto\", \"adaptive\", \"disabled\"}\n\tif len(cap.Levels) != len(wantLevels) {\n\t\tt.Fatalf(\"levels = %v, want %v\", cap.Levels, wantLevels)\n\t}\n\tfor i, l := range wantLevels {\n\t\tif cap.Levels[i] != l {\n\t\t\tt.Errorf(\"levels[%d] = %q, want %q\", i, cap.Levels[i], l)\n\t\t}\n\t}\n\tif cap.Default != \"adaptive\" {\n\t\tt.Errorf(\"default = %q, want adaptive (M3 ships with thinking on)\", cap.Default)\n\t}\n}\n\nfunc TestNormalizeEffortMiniMax(t *testing.T) {\n\te := &ProviderEntry{Kind: \"openai\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\"}\n\tcases := []struct {\n\t\tin, want string\n\t}{\n\t\t{\"auto\", \"\"}, // auto == \"leave to provider default\" == empty\n\t\t{\"adaptive\", \"adaptive\"},\n\t\t{\"disabled\", \"disabled\"},\n\t\t{\"ADAPTIVE\", \"adaptive\"}, // case-insensitive\n\t\t// Stale values from other vendors still resolve to a valid M3 level\n\t\t// rather than failing the /effort command.\n\t\t{\"off\", \"disabled\"}, // retired DeepSeek \"no thinking\" → M3 actually supports \"disabled\"\n\t\t{\"low\", \"adaptive\"},\n\t\t{\"medium\", \"adaptive\"},\n\t\t{\"high\", \"adaptive\"},\n\t\t{\"xhigh\", \"disabled\"},\n\t\t{\"max\", \"disabled\"},\n\t}\n\tfor _, tc := range cases {\n\t\tgot, err := NormalizeEffort(e, tc.in)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"NormalizeEffort(%q) returned error: %v\", tc.in, err)\n\t\t\tcontinue\n\t\t}\n\t\tif got != tc.want {\n\t\t\tt.Errorf(\"NormalizeEffort(%q) = %q, want %q\", tc.in, got, tc.want)\n\t\t}\n\t}\n}\n\nfunc TestNormalizeEffortMiniMaxRejectsGarbage(t *testing.T) {\n\te := &ProviderEntry{Kind: \"openai\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\"}\n\t// \"turbo\" and similar unrecognised inputs reach the MiniMax switch and\n\t// are rejected with the MiniMax-specific usage hint. Empty input is\n\t// rejected earlier with the generic hint; both are valid user-facing\n\t// errors. \"off\" is *not* in this list — it's a retired level we now\n\t// migrate to \"adaptive\" (tested in TestNormalizeEffortMiniMax above).\n\tcases := map[string]string{\n\t\t\"turbo\": \"auto|adaptive|disabled\",\n\t\t\"\": \"auto|\",\n\t}\n\tfor in, wantHint := range cases {\n\t\t_, err := NormalizeEffort(e, in)\n\t\tif err == nil {\n\t\t\tt.Errorf(\"NormalizeEffort(%q) should be rejected\", in)\n\t\t\tcontinue\n\t\t}\n\t\tif !strings.Contains(err.Error(), wantHint) {\n\t\t\tt.Errorf(\"NormalizeEffort(%q) error = %q, want it to mention %q\", in, err.Error(), wantHint)\n\t\t}\n\t}\n}\n\nfunc TestEffectiveEffortMiniMax(t *testing.T) {\n\te := &ProviderEntry{Kind: \"openai\", BaseURL: \"https://api.minimaxi.com/v1\", Model: \"MiniMax-M3\"}\n\t// unset → EffectiveEffort stays empty so the wire layer defaults to adaptive\n\tif got := EffectiveEffort(e); got != \"\" {\n\t\tt.Errorf(\"unset EffectiveEffort = %q, want \\\"\\\"\", got)\n\t}\n\t// explicit value is preserved verbatim\n\te.Effort = \"disabled\"\n\tif got := EffectiveEffort(e); got != \"disabled\" {\n\t\tt.Errorf(\"explicit EffectiveEffort = %q, want disabled\", got)\n\t}\n}\n"}}, "internal/provider/openai/openai.go": {"c480afd7570fbb1fb96ff82a0ab117c5df7768e8": {"t": 1785746073.8195908, "n": 21869, "h": "847331cb51fd815a", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, MiniMax-M3, and\n// any other OpenAI-compatible endpoint are just config instances rather than\n// code. Each instance picks the wire shape from its base URL:\n// - api.deepseek.com → emits thinking.type=enabled (DeepSeek-flavor CoT) plus\n// reasoning_effort as a depth hint.\n// - api.minimaxi.com → emits thinking.type=adaptive|disabled (M3's binary\n// knob) instead of reasoning_effort, since M3 has no level scale.\n// - everything else (MiMo and other OpenAI-compatible gateways) uses the\n// vanilla reasoning_effort scale (low/medium/high).\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\n// defaultStreamIdleTimeout caps how long a started SSE stream may go without any\n// bytes before it's treated as a dropped connection. A half-open TCP connection\n// (e.g. a proxy switched mid-stream) sends no RST, so scanner.Scan() would block\n// forever; this turns that hang into a recoverable error. Generous on purpose —\n// live streams emit tokens/keepalives far more often. Stored per-client\n// (client.idleTimeout) so a test can shorten it without a shared global that\n// would race other streams' watchdogs.\nconst defaultStreamIdleTimeout = 120 * time.Second\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\teffort = strings.ToLower(strings.TrimSpace(effort))\n\tif effort == \"auto\" {\n\t\teffort = \"\"\n\t}\n\tprotocol, _ := cfg.Extra[\"reasoning_protocol\"].(string)\n\tprotocol = normalizeReasoningProtocol(protocol)\n\tvision, _ := cfg.Extra[\"vision\"].(bool)\n\tvisionDetail, _ := cfg.Extra[\"vision_detail\"].(string)\n\tvisionDetail = strings.ToLower(strings.TrimSpace(visionDetail))\n\tif visionDetail != \"low\" && visionDetail != \"high\" {\n\t\tvisionDetail = \"\" // auto — omit the field\n\t}\n\tdeepseek := protocol == \"deepseek\" || (protocol == \"\" && IsDeepSeek(cfg.BaseURL))\n\tminimax := protocol == \"\" && IsMiniMax(cfg.BaseURL)\n\tswitch {\n\tcase protocol == \"none\":\n\t\teffort = \"\"\n\tcase deepseek:\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\tcase minimax:\n\t\t// M3's knob is binary. The config effort layer normalises user input\n\t\t// to \"adaptive\", \"disabled\", or \"\" (== auto). We keep \"high\"/\"max\"\n\t\t// (legacy DeepSeek) and \"low\"/\"medium\" (Anthropic) out — config-level\n\t\t// NormalizeEffort remaps them to \"adaptive\" already, so anything\n\t\t// reaching here is expected to be one of: \"\", \"adaptive\", \"disabled\".\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\": // auto — leave empty so the wire emits thinking.type=adaptive\n\t\tcase \"adaptive\", \"disabled\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses MiniMax thinking; effort must be adaptive or disabled\", name)\n\t\t}\n\tcase effort != \"\":\n\t\t// Non-DeepSeek backends use OpenAI's reasoning_effort scale (low/medium/\n\t\t// high); \"max\" is a DeepSeek-ism MiMo et al. reject with 400, so clamp it\n\t\t// to the OpenAI ceiling and reject other values at boot, not at request time.\n\t\tswitch effort {\n\t\tcase \"max\":\n\t\t\teffort = \"high\"\n\t\tcase \"low\", \"medium\", \"high\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q: effort must be low, medium, or high\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\tminimax: minimax,\n\t\tvision: vision,\n\t\tvisionDetail: visionDetail,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t\tidleTimeout: defaultStreamIdleTimeout,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\tminimax bool // true for api.minimaxi.com — emits MiniMax-M3's thinking knob instead of reasoning_effort\n\tvision bool // model accepts image input — embed attached images as image_url parts\n\tvisionDetail string // image_url detail hint (low|high); \"\" = auto/omit\n\teffort string // reasoning_effort for OpenAI; thinking.type for MiniMax; \"\" = auto/provider default\n\tidleTimeout time.Duration // SSE stall watchdog window; defaultStreamIdleTimeout unless a test overrides\n\tauthed atomic.Bool // a request has succeeded — gate transient-401 retry\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc (c *client) sendOpts() provider.SendOptions {\n\treturn provider.SendOptions{\n\t\tProvider: c.name,\n\t\tKeyEnv: c.keyEnv,\n\t\tKeyPresent: c.apiKey != \"\",\n\t\tRetryAuth: c.authed.Load(),\n\t}\n}\n\nfunc normalizeReasoningProtocol(raw string) string {\n\tswitch strings.ToLower(strings.TrimSpace(raw)) {\n\tcase \"deepseek\", \"openai\", \"none\":\n\t\treturn strings.ToLower(strings.TrimSpace(raw))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn\n// allocates a buffer, marshals the request, and sends it — pooling avoids the\n// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is\n// provider-level (not global) so OpenAI and Anthropic don't compete.\nvar bufPool = sync.Pool{\n\tNew: func() any { return new(bytes.Buffer) },\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbuf := bufPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tif err := json.NewEncoder(buf).Encode(c.buildRequest(req)); err != nil {\n\t\tbufPool.Put(buf)\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\tbody := make([]byte, buf.Len())\n\tcopy(body, buf.Bytes())\n\tbufPool.Put(buf)\n\n\tnewReq := func(ctx context.Context) (*http.Request, error) {\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn httpReq, nil\n\t}\n\tresp, err := provider.SendWithRetry(ctx, c.http, c.sendOpts(), newReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tc.authed.Store(true)\n\n\tout := make(chan provider.Chunk)\n\tgo c.streamWithReconnect(ctx, resp, newReq, out)\n\treturn out, nil\n}\n\n// maxStreamReconnects bounds how many times a mid-stream connection drop is\n// replayed from scratch before the error is surfaced — each replay re-runs the\n// whole request (cheap under prompt caching, but not free).\nconst maxStreamReconnects = 3\n\n// streamWithReconnect drives readStream and, when the connection is cut before\n// any model output has been forwarded, replays the request rather than failing\n// the turn. Once a token (reasoning/text/tool-call) has been emitted, a replay\n// would duplicate output, so the error is surfaced instead.\nfunc (c *client) streamWithReconnect(ctx context.Context, resp *http.Response, newReq func(context.Context) (*http.Request, error), out chan<- provider.Chunk) {\n\tdefer close(out)\n\tfor attempt := 0; ; attempt++ {\n\t\temitted, err := c.readStream(ctx, resp, out)\n\t\tif err == nil {\n\t\t\treturn\n\t\t}\n\t\tif !provider.IsConnReset(err) {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tif emitted {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: &provider.StreamInterruptedError{Err: err}}\n\t\t\treturn\n\t\t}\n\t\tif attempt >= maxStreamReconnects {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tnext, rerr := provider.SendWithRetry(ctx, c.http, c.sendOpts(), newReq)\n\t\tif rerr != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: rerr}\n\t\t\treturn\n\t\t}\n\t\tresp = next\n\t}\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\t// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was\n\t\t// dropped on a cache-miss replay (\"reasoning_content … must be passed back\"),\n\t\t// so round it back — but only on the turn that carries the tool calls.\n\t\tif c.deepseek && m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\tcm.ReasoningContent = m.ReasoningContent\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tswitch {\n\t\tcase c.vision && m.Role == provider.RoleUser && len(m.Images) > 0:\n\t\t\tcm.Content = imageContentParts(m.Content, m.Images, c.visionDetail)\n\t\tcase m.Role != provider.RoleAssistant || len(cm.ToolCalls) == 0 || m.Content != \"\":\n\t\t\tcm.Content = m.Content\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tswitch {\n\tcase c.deepseek:\n\t\t// DeepSeek's CoT is controlled by `thinking` (always on) plus\n\t\t// `reasoning_effort` for depth. We never disable thinking for DeepSeek.\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\tcase c.minimax:\n\t\t// M3 uses a single `thinking.type` field with two valid values:\n\t\t// \"adaptive\" (default, thinking on) and \"disabled\" (off). Reasoning\n\t\t// depth is not a knob on M3, so reasoning_effort is omitted entirely.\n\t\tt := c.effort\n\t\tif t == \"\" {\n\t\t\tt = \"adaptive\" // /effort auto == the M3 model default\n\t\t}\n\t\tout.Thinking = &thinkingMode{Type: t}\n\t\tout.ReasoningEffort = \"\"\n\t}\n\treturn out\n}\n\n// readStream parses one SSE response into chunks: text deltas stream live,\n// tool-call fragments accumulate by index and emit complete on [DONE], and a\n// ChunkToolCallStart fires the moment a call's name is known. It returns whether\n// any model output was forwarded (so the caller can decide a replay is safe) and\n// the first fatal error — a nil error means the stream reached [DONE].\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) (emitted bool, _ error) {\n\tdefer resp.Body.Close()\n\n\t// Close the response body when the context is canceled (user interrupt) or the\n\t// stream stalls past c.idleTimeout, so scanner.Scan() unblocks instead of\n\t// hanging on a half-open connection. done lets the watchdog exit on a normal\n\t// return — otherwise it outlives the call and blocks forever on a non-cancellable\n\t// context whose Done() is nil. The watchdog owns the timer; the read loop only\n\t// pings the buffered activity channel, so there's no Timer.Reset race.\n\tidleTimeout := c.idleTimeout\n\tif idleTimeout <= 0 { // zero-value client (constructed without New)\n\t\tidleTimeout = defaultStreamIdleTimeout\n\t}\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tactivity := make(chan struct{}, 1)\n\tvar stalled atomic.Bool\n\tgo func() {\n\t\tidle := time.NewTimer(idleTimeout)\n\t\tdefer idle.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\tresp.Body.Close()\n\t\t\t\treturn\n\t\t\tcase <-idle.C:\n\t\t\t\tstalled.Store(true)\n\t\t\t\tresp.Body.Close()\n\t\t\t\treturn\n\t\t\tcase <-activity:\n\t\t\t\tif !idle.Stop() {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase <-idle.C:\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tidle.Reset(idleTimeout)\n\t\t\tcase <-done:\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar sawDone bool\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tselect { // ping the idle watchdog; non-blocking so a full buffer is fine\n\t\tcase activity <- struct{}{}:\n\t\tdefault:\n\t\t}\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tsawDone = true\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: decode stream: %w\", c.name, err)\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif stalled.Load() {\n\t\treturn emitted, fmt.Errorf(\"%s: stream stalled — no data for %s, connection likely dropped\", c.name, idleTimeout)\n\t}\n\tif err := scanner.Err(); err != nil {\n\t\treturn emitted, fmt.Errorf(\"%s: read stream: %w\", c.name, err)\n\t}\n\t// A proxy that idle-closes with a clean FIN ends the scan with no error. Without\n\t// this check the turn would be committed as complete — including half-streamed\n\t// tool-call arguments, which then 400 on every replay (#3953).\n\tif !sawDone && lastFinishReason == \"\" {\n\t\treturn emitted, fmt.Errorf(\"%s: stream ended before completion: %w\", c.name, io.ErrUnexpectedEOF)\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n\treturn emitted, nil\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always present (never omitted): DeepSeek's strict deserializer\n\t// rejects a message missing the field. A pure tool_calls assistant turn\n\t// serializes as null (nil here); a string for every other text message\n\t// (empty included — null is rejected by some backends for a tool message);\n\t// and a []chatContentPart array for a vision user turn carrying images.\n\tContent any `json:\"content\"`\n\tReasoningContent string `json:\"reasoning_content,omitempty\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n}\n\ntype chatContentPart struct {\n\tType string `json:\"type\"`\n\tText string `json:\"text,omitempty\"`\n\tImageURL *chatImageURL `json:\"image_url,omitempty\"`\n}\n\ntype chatImageURL struct {\n\tURL string `json:\"url\"`\n\tDetail string `json:\"detail,omitempty\"`\n}\n\nfunc imageContentParts(text string, images []string, detail string) []chatContentPart {\n\tparts := make([]chatContentPart, 0, len(images)+1)\n\tif text != \"\" {\n\t\tparts = append(parts, chatContentPart{Type: \"text\", Text: text})\n\t}\n\tfor _, url := range images {\n\t\tparts = append(parts, chatContentPart{Type: \"image_url\", ImageURL: &chatImageURL{URL: url, Detail: detail}})\n\t}\n\treturn parts\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "62645d1c53d7728c4382926ffcf06364b418cc46": {"t": 1785750694.425603, "n": 21869, "h": "847331cb51fd815a", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, MiniMax-M3, and\n// any other OpenAI-compatible endpoint are just config instances rather than\n// code. Each instance picks the wire shape from its base URL:\n// - api.deepseek.com → emits thinking.type=enabled (DeepSeek-flavor CoT) plus\n// reasoning_effort as a depth hint.\n// - api.minimaxi.com → emits thinking.type=adaptive|disabled (M3's binary\n// knob) instead of reasoning_effort, since M3 has no level scale.\n// - everything else (MiMo and other OpenAI-compatible gateways) uses the\n// vanilla reasoning_effort scale (low/medium/high).\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\n// defaultStreamIdleTimeout caps how long a started SSE stream may go without any\n// bytes before it's treated as a dropped connection. A half-open TCP connection\n// (e.g. a proxy switched mid-stream) sends no RST, so scanner.Scan() would block\n// forever; this turns that hang into a recoverable error. Generous on purpose —\n// live streams emit tokens/keepalives far more often. Stored per-client\n// (client.idleTimeout) so a test can shorten it without a shared global that\n// would race other streams' watchdogs.\nconst defaultStreamIdleTimeout = 120 * time.Second\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\teffort = strings.ToLower(strings.TrimSpace(effort))\n\tif effort == \"auto\" {\n\t\teffort = \"\"\n\t}\n\tprotocol, _ := cfg.Extra[\"reasoning_protocol\"].(string)\n\tprotocol = normalizeReasoningProtocol(protocol)\n\tvision, _ := cfg.Extra[\"vision\"].(bool)\n\tvisionDetail, _ := cfg.Extra[\"vision_detail\"].(string)\n\tvisionDetail = strings.ToLower(strings.TrimSpace(visionDetail))\n\tif visionDetail != \"low\" && visionDetail != \"high\" {\n\t\tvisionDetail = \"\" // auto — omit the field\n\t}\n\tdeepseek := protocol == \"deepseek\" || (protocol == \"\" && IsDeepSeek(cfg.BaseURL))\n\tminimax := protocol == \"\" && IsMiniMax(cfg.BaseURL)\n\tswitch {\n\tcase protocol == \"none\":\n\t\teffort = \"\"\n\tcase deepseek:\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\tcase minimax:\n\t\t// M3's knob is binary. The config effort layer normalises user input\n\t\t// to \"adaptive\", \"disabled\", or \"\" (== auto). We keep \"high\"/\"max\"\n\t\t// (legacy DeepSeek) and \"low\"/\"medium\" (Anthropic) out — config-level\n\t\t// NormalizeEffort remaps them to \"adaptive\" already, so anything\n\t\t// reaching here is expected to be one of: \"\", \"adaptive\", \"disabled\".\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\": // auto — leave empty so the wire emits thinking.type=adaptive\n\t\tcase \"adaptive\", \"disabled\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses MiniMax thinking; effort must be adaptive or disabled\", name)\n\t\t}\n\tcase effort != \"\":\n\t\t// Non-DeepSeek backends use OpenAI's reasoning_effort scale (low/medium/\n\t\t// high); \"max\" is a DeepSeek-ism MiMo et al. reject with 400, so clamp it\n\t\t// to the OpenAI ceiling and reject other values at boot, not at request time.\n\t\tswitch effort {\n\t\tcase \"max\":\n\t\t\teffort = \"high\"\n\t\tcase \"low\", \"medium\", \"high\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q: effort must be low, medium, or high\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\tminimax: minimax,\n\t\tvision: vision,\n\t\tvisionDetail: visionDetail,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t\tidleTimeout: defaultStreamIdleTimeout,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\tminimax bool // true for api.minimaxi.com — emits MiniMax-M3's thinking knob instead of reasoning_effort\n\tvision bool // model accepts image input — embed attached images as image_url parts\n\tvisionDetail string // image_url detail hint (low|high); \"\" = auto/omit\n\teffort string // reasoning_effort for OpenAI; thinking.type for MiniMax; \"\" = auto/provider default\n\tidleTimeout time.Duration // SSE stall watchdog window; defaultStreamIdleTimeout unless a test overrides\n\tauthed atomic.Bool // a request has succeeded — gate transient-401 retry\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc (c *client) sendOpts() provider.SendOptions {\n\treturn provider.SendOptions{\n\t\tProvider: c.name,\n\t\tKeyEnv: c.keyEnv,\n\t\tKeyPresent: c.apiKey != \"\",\n\t\tRetryAuth: c.authed.Load(),\n\t}\n}\n\nfunc normalizeReasoningProtocol(raw string) string {\n\tswitch strings.ToLower(strings.TrimSpace(raw)) {\n\tcase \"deepseek\", \"openai\", \"none\":\n\t\treturn strings.ToLower(strings.TrimSpace(raw))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn\n// allocates a buffer, marshals the request, and sends it — pooling avoids the\n// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is\n// provider-level (not global) so OpenAI and Anthropic don't compete.\nvar bufPool = sync.Pool{\n\tNew: func() any { return new(bytes.Buffer) },\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbuf := bufPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tif err := json.NewEncoder(buf).Encode(c.buildRequest(req)); err != nil {\n\t\tbufPool.Put(buf)\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\tbody := make([]byte, buf.Len())\n\tcopy(body, buf.Bytes())\n\tbufPool.Put(buf)\n\n\tnewReq := func(ctx context.Context) (*http.Request, error) {\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn httpReq, nil\n\t}\n\tresp, err := provider.SendWithRetry(ctx, c.http, c.sendOpts(), newReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tc.authed.Store(true)\n\n\tout := make(chan provider.Chunk)\n\tgo c.streamWithReconnect(ctx, resp, newReq, out)\n\treturn out, nil\n}\n\n// maxStreamReconnects bounds how many times a mid-stream connection drop is\n// replayed from scratch before the error is surfaced — each replay re-runs the\n// whole request (cheap under prompt caching, but not free).\nconst maxStreamReconnects = 3\n\n// streamWithReconnect drives readStream and, when the connection is cut before\n// any model output has been forwarded, replays the request rather than failing\n// the turn. Once a token (reasoning/text/tool-call) has been emitted, a replay\n// would duplicate output, so the error is surfaced instead.\nfunc (c *client) streamWithReconnect(ctx context.Context, resp *http.Response, newReq func(context.Context) (*http.Request, error), out chan<- provider.Chunk) {\n\tdefer close(out)\n\tfor attempt := 0; ; attempt++ {\n\t\temitted, err := c.readStream(ctx, resp, out)\n\t\tif err == nil {\n\t\t\treturn\n\t\t}\n\t\tif !provider.IsConnReset(err) {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tif emitted {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: &provider.StreamInterruptedError{Err: err}}\n\t\t\treturn\n\t\t}\n\t\tif attempt >= maxStreamReconnects {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tnext, rerr := provider.SendWithRetry(ctx, c.http, c.sendOpts(), newReq)\n\t\tif rerr != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: rerr}\n\t\t\treturn\n\t\t}\n\t\tresp = next\n\t}\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\t// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was\n\t\t// dropped on a cache-miss replay (\"reasoning_content … must be passed back\"),\n\t\t// so round it back — but only on the turn that carries the tool calls.\n\t\tif c.deepseek && m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\tcm.ReasoningContent = m.ReasoningContent\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tswitch {\n\t\tcase c.vision && m.Role == provider.RoleUser && len(m.Images) > 0:\n\t\t\tcm.Content = imageContentParts(m.Content, m.Images, c.visionDetail)\n\t\tcase m.Role != provider.RoleAssistant || len(cm.ToolCalls) == 0 || m.Content != \"\":\n\t\t\tcm.Content = m.Content\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tswitch {\n\tcase c.deepseek:\n\t\t// DeepSeek's CoT is controlled by `thinking` (always on) plus\n\t\t// `reasoning_effort` for depth. We never disable thinking for DeepSeek.\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\tcase c.minimax:\n\t\t// M3 uses a single `thinking.type` field with two valid values:\n\t\t// \"adaptive\" (default, thinking on) and \"disabled\" (off). Reasoning\n\t\t// depth is not a knob on M3, so reasoning_effort is omitted entirely.\n\t\tt := c.effort\n\t\tif t == \"\" {\n\t\t\tt = \"adaptive\" // /effort auto == the M3 model default\n\t\t}\n\t\tout.Thinking = &thinkingMode{Type: t}\n\t\tout.ReasoningEffort = \"\"\n\t}\n\treturn out\n}\n\n// readStream parses one SSE response into chunks: text deltas stream live,\n// tool-call fragments accumulate by index and emit complete on [DONE], and a\n// ChunkToolCallStart fires the moment a call's name is known. It returns whether\n// any model output was forwarded (so the caller can decide a replay is safe) and\n// the first fatal error — a nil error means the stream reached [DONE].\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) (emitted bool, _ error) {\n\tdefer resp.Body.Close()\n\n\t// Close the response body when the context is canceled (user interrupt) or the\n\t// stream stalls past c.idleTimeout, so scanner.Scan() unblocks instead of\n\t// hanging on a half-open connection. done lets the watchdog exit on a normal\n\t// return — otherwise it outlives the call and blocks forever on a non-cancellable\n\t// context whose Done() is nil. The watchdog owns the timer; the read loop only\n\t// pings the buffered activity channel, so there's no Timer.Reset race.\n\tidleTimeout := c.idleTimeout\n\tif idleTimeout <= 0 { // zero-value client (constructed without New)\n\t\tidleTimeout = defaultStreamIdleTimeout\n\t}\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tactivity := make(chan struct{}, 1)\n\tvar stalled atomic.Bool\n\tgo func() {\n\t\tidle := time.NewTimer(idleTimeout)\n\t\tdefer idle.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\tresp.Body.Close()\n\t\t\t\treturn\n\t\t\tcase <-idle.C:\n\t\t\t\tstalled.Store(true)\n\t\t\t\tresp.Body.Close()\n\t\t\t\treturn\n\t\t\tcase <-activity:\n\t\t\t\tif !idle.Stop() {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase <-idle.C:\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tidle.Reset(idleTimeout)\n\t\t\tcase <-done:\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar sawDone bool\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tselect { // ping the idle watchdog; non-blocking so a full buffer is fine\n\t\tcase activity <- struct{}{}:\n\t\tdefault:\n\t\t}\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tsawDone = true\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: decode stream: %w\", c.name, err)\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif stalled.Load() {\n\t\treturn emitted, fmt.Errorf(\"%s: stream stalled — no data for %s, connection likely dropped\", c.name, idleTimeout)\n\t}\n\tif err := scanner.Err(); err != nil {\n\t\treturn emitted, fmt.Errorf(\"%s: read stream: %w\", c.name, err)\n\t}\n\t// A proxy that idle-closes with a clean FIN ends the scan with no error. Without\n\t// this check the turn would be committed as complete — including half-streamed\n\t// tool-call arguments, which then 400 on every replay (#3953).\n\tif !sawDone && lastFinishReason == \"\" {\n\t\treturn emitted, fmt.Errorf(\"%s: stream ended before completion: %w\", c.name, io.ErrUnexpectedEOF)\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n\treturn emitted, nil\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always present (never omitted): DeepSeek's strict deserializer\n\t// rejects a message missing the field. A pure tool_calls assistant turn\n\t// serializes as null (nil here); a string for every other text message\n\t// (empty included — null is rejected by some backends for a tool message);\n\t// and a []chatContentPart array for a vision user turn carrying images.\n\tContent any `json:\"content\"`\n\tReasoningContent string `json:\"reasoning_content,omitempty\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n}\n\ntype chatContentPart struct {\n\tType string `json:\"type\"`\n\tText string `json:\"text,omitempty\"`\n\tImageURL *chatImageURL `json:\"image_url,omitempty\"`\n}\n\ntype chatImageURL struct {\n\tURL string `json:\"url\"`\n\tDetail string `json:\"detail,omitempty\"`\n}\n\nfunc imageContentParts(text string, images []string, detail string) []chatContentPart {\n\tparts := make([]chatContentPart, 0, len(images)+1)\n\tif text != \"\" {\n\t\tparts = append(parts, chatContentPart{Type: \"text\", Text: text})\n\t}\n\tfor _, url := range images {\n\t\tparts = append(parts, chatContentPart{Type: \"image_url\", ImageURL: &chatImageURL{URL: url, Detail: detail}})\n\t}\n\treturn parts\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "278984fade01052b76608278338f2774fc68957a": {"t": 1785750695.6318483, "n": 21422, "h": "f4f8b53659b7c28d", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, MiniMax-M3, and\n// any other OpenAI-compatible endpoint are just config instances rather than\n// code. Each instance picks the wire shape from its base URL:\n// - api.deepseek.com → emits thinking.type=enabled (DeepSeek-flavor CoT) plus\n// reasoning_effort as a depth hint.\n// - api.minimaxi.com → emits thinking.type=adaptive|disabled (M3's binary\n// knob) instead of reasoning_effort, since M3 has no level scale.\n// - everything else (MiMo and other OpenAI-compatible gateways) uses the\n// vanilla reasoning_effort scale (low/medium/high).\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\n// defaultStreamIdleTimeout caps how long a started SSE stream may go without any\n// bytes before it's treated as a dropped connection. A half-open TCP connection\n// (e.g. a proxy switched mid-stream) sends no RST, so scanner.Scan() would block\n// forever; this turns that hang into a recoverable error. Generous on purpose —\n// live streams emit tokens/keepalives far more often. Stored per-client\n// (client.idleTimeout) so a test can shorten it without a shared global that\n// would race other streams' watchdogs.\nconst defaultStreamIdleTimeout = 120 * time.Second\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\teffort = strings.ToLower(strings.TrimSpace(effort))\n\tif effort == \"auto\" {\n\t\teffort = \"\"\n\t}\n\tprotocol, _ := cfg.Extra[\"reasoning_protocol\"].(string)\n\tprotocol = normalizeReasoningProtocol(protocol)\n\tvision, _ := cfg.Extra[\"vision\"].(bool)\n\tdeepseek := protocol == \"deepseek\" || (protocol == \"\" && IsDeepSeek(cfg.BaseURL))\n\tminimax := protocol == \"\" && IsMiniMax(cfg.BaseURL)\n\tswitch {\n\tcase protocol == \"none\":\n\t\teffort = \"\"\n\tcase deepseek:\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\tcase minimax:\n\t\t// M3's knob is binary. The config effort layer normalises user input\n\t\t// to \"adaptive\", \"disabled\", or \"\" (== auto). We keep \"high\"/\"max\"\n\t\t// (legacy DeepSeek) and \"low\"/\"medium\" (Anthropic) out — config-level\n\t\t// NormalizeEffort remaps them to \"adaptive\" already, so anything\n\t\t// reaching here is expected to be one of: \"\", \"adaptive\", \"disabled\".\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\": // auto — leave empty so the wire emits thinking.type=adaptive\n\t\tcase \"adaptive\", \"disabled\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses MiniMax thinking; effort must be adaptive or disabled\", name)\n\t\t}\n\tcase effort != \"\":\n\t\t// Non-DeepSeek backends use OpenAI's reasoning_effort scale (low/medium/\n\t\t// high); \"max\" is a DeepSeek-ism MiMo et al. reject with 400, so clamp it\n\t\t// to the OpenAI ceiling and reject other values at boot, not at request time.\n\t\tswitch effort {\n\t\tcase \"max\":\n\t\t\teffort = \"high\"\n\t\tcase \"low\", \"medium\", \"high\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q: effort must be low, medium, or high\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\tminimax: minimax,\n\t\tvision: vision,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t\tidleTimeout: defaultStreamIdleTimeout,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\tminimax bool // true for api.minimaxi.com — emits MiniMax-M3's thinking knob instead of reasoning_effort\n\tvision bool // model accepts image input — embed attached images as image_url parts\n\teffort string // reasoning_effort for OpenAI; thinking.type for MiniMax; \"\" = auto/provider default\n\tidleTimeout time.Duration // SSE stall watchdog window; defaultStreamIdleTimeout unless a test overrides\n\tauthed atomic.Bool // a request has succeeded — gate transient-401 retry\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc (c *client) sendOpts() provider.SendOptions {\n\treturn provider.SendOptions{\n\t\tProvider: c.name,\n\t\tKeyEnv: c.keyEnv,\n\t\tKeyPresent: c.apiKey != \"\",\n\t\tRetryAuth: c.authed.Load(),\n\t}\n}\n\nfunc normalizeReasoningProtocol(raw string) string {\n\tswitch strings.ToLower(strings.TrimSpace(raw)) {\n\tcase \"deepseek\", \"openai\", \"none\":\n\t\treturn strings.ToLower(strings.TrimSpace(raw))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn\n// allocates a buffer, marshals the request, and sends it — pooling avoids the\n// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is\n// provider-level (not global) so OpenAI and Anthropic don't compete.\nvar bufPool = sync.Pool{\n\tNew: func() any { return new(bytes.Buffer) },\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbuf := bufPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tif err := json.NewEncoder(buf).Encode(c.buildRequest(req)); err != nil {\n\t\tbufPool.Put(buf)\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\tbody := make([]byte, buf.Len())\n\tcopy(body, buf.Bytes())\n\tbufPool.Put(buf)\n\n\tnewReq := func(ctx context.Context) (*http.Request, error) {\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn httpReq, nil\n\t}\n\tresp, err := provider.SendWithRetry(ctx, c.http, c.sendOpts(), newReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tc.authed.Store(true)\n\n\tout := make(chan provider.Chunk)\n\tgo c.streamWithReconnect(ctx, resp, newReq, out)\n\treturn out, nil\n}\n\n// maxStreamReconnects bounds how many times a mid-stream connection drop is\n// replayed from scratch before the error is surfaced — each replay re-runs the\n// whole request (cheap under prompt caching, but not free).\nconst maxStreamReconnects = 3\n\n// streamWithReconnect drives readStream and, when the connection is cut before\n// any model output has been forwarded, replays the request rather than failing\n// the turn. Once a token (reasoning/text/tool-call) has been emitted, a replay\n// would duplicate output, so the error is surfaced instead.\nfunc (c *client) streamWithReconnect(ctx context.Context, resp *http.Response, newReq func(context.Context) (*http.Request, error), out chan<- provider.Chunk) {\n\tdefer close(out)\n\tfor attempt := 0; ; attempt++ {\n\t\temitted, err := c.readStream(ctx, resp, out)\n\t\tif err == nil {\n\t\t\treturn\n\t\t}\n\t\tif !provider.IsConnReset(err) {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tif emitted {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: &provider.StreamInterruptedError{Err: err}}\n\t\t\treturn\n\t\t}\n\t\tif attempt >= maxStreamReconnects {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tnext, rerr := provider.SendWithRetry(ctx, c.http, c.sendOpts(), newReq)\n\t\tif rerr != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: rerr}\n\t\t\treturn\n\t\t}\n\t\tresp = next\n\t}\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\t// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was\n\t\t// dropped on a cache-miss replay (\"reasoning_content … must be passed back\"),\n\t\t// so round it back — but only on the turn that carries the tool calls.\n\t\tif c.deepseek && m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\tcm.ReasoningContent = m.ReasoningContent\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tswitch {\n\t\tcase c.vision && m.Role == provider.RoleUser && len(m.Images) > 0:\n\t\t\tcm.Content = imageContentParts(m.Content, m.Images)\n\t\tcase m.Role != provider.RoleAssistant || len(cm.ToolCalls) == 0 || m.Content != \"\":\n\t\t\tcm.Content = m.Content\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tswitch {\n\tcase c.deepseek:\n\t\t// DeepSeek's CoT is controlled by `thinking` (always on) plus\n\t\t// `reasoning_effort` for depth. We never disable thinking for DeepSeek.\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\tcase c.minimax:\n\t\t// M3 uses a single `thinking.type` field with two valid values:\n\t\t// \"adaptive\" (default, thinking on) and \"disabled\" (off). Reasoning\n\t\t// depth is not a knob on M3, so reasoning_effort is omitted entirely.\n\t\tt := c.effort\n\t\tif t == \"\" {\n\t\t\tt = \"adaptive\" // /effort auto == the M3 model default\n\t\t}\n\t\tout.Thinking = &thinkingMode{Type: t}\n\t\tout.ReasoningEffort = \"\"\n\t}\n\treturn out\n}\n\n// readStream parses one SSE response into chunks: text deltas stream live,\n// tool-call fragments accumulate by index and emit complete on [DONE], and a\n// ChunkToolCallStart fires the moment a call's name is known. It returns whether\n// any model output was forwarded (so the caller can decide a replay is safe) and\n// the first fatal error — a nil error means the stream reached [DONE].\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) (emitted bool, _ error) {\n\tdefer resp.Body.Close()\n\n\t// Close the response body when the context is canceled (user interrupt) or the\n\t// stream stalls past c.idleTimeout, so scanner.Scan() unblocks instead of\n\t// hanging on a half-open connection. done lets the watchdog exit on a normal\n\t// return — otherwise it outlives the call and blocks forever on a non-cancellable\n\t// context whose Done() is nil. The watchdog owns the timer; the read loop only\n\t// pings the buffered activity channel, so there's no Timer.Reset race.\n\tidleTimeout := c.idleTimeout\n\tif idleTimeout <= 0 { // zero-value client (constructed without New)\n\t\tidleTimeout = defaultStreamIdleTimeout\n\t}\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tactivity := make(chan struct{}, 1)\n\tvar stalled atomic.Bool\n\tgo func() {\n\t\tidle := time.NewTimer(idleTimeout)\n\t\tdefer idle.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\tresp.Body.Close()\n\t\t\t\treturn\n\t\t\tcase <-idle.C:\n\t\t\t\tstalled.Store(true)\n\t\t\t\tresp.Body.Close()\n\t\t\t\treturn\n\t\t\tcase <-activity:\n\t\t\t\tif !idle.Stop() {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase <-idle.C:\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tidle.Reset(idleTimeout)\n\t\t\tcase <-done:\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar sawDone bool\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tselect { // ping the idle watchdog; non-blocking so a full buffer is fine\n\t\tcase activity <- struct{}{}:\n\t\tdefault:\n\t\t}\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tsawDone = true\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: decode stream: %w\", c.name, err)\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif stalled.Load() {\n\t\treturn emitted, fmt.Errorf(\"%s: stream stalled — no data for %s, connection likely dropped\", c.name, idleTimeout)\n\t}\n\tif err := scanner.Err(); err != nil {\n\t\treturn emitted, fmt.Errorf(\"%s: read stream: %w\", c.name, err)\n\t}\n\t// A proxy that idle-closes with a clean FIN ends the scan with no error. Without\n\t// this check the turn would be committed as complete — including half-streamed\n\t// tool-call arguments, which then 400 on every replay (#3953).\n\tif !sawDone && lastFinishReason == \"\" {\n\t\treturn emitted, fmt.Errorf(\"%s: stream ended before completion: %w\", c.name, io.ErrUnexpectedEOF)\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n\treturn emitted, nil\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always present (never omitted): DeepSeek's strict deserializer\n\t// rejects a message missing the field. A pure tool_calls assistant turn\n\t// serializes as null (nil here); a string for every other text message\n\t// (empty included — null is rejected by some backends for a tool message);\n\t// and a []chatContentPart array for a vision user turn carrying images.\n\tContent any `json:\"content\"`\n\tReasoningContent string `json:\"reasoning_content,omitempty\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n}\n\ntype chatContentPart struct {\n\tType string `json:\"type\"`\n\tText string `json:\"text,omitempty\"`\n\tImageURL *chatImageURL `json:\"image_url,omitempty\"`\n}\n\ntype chatImageURL struct {\n\tURL string `json:\"url\"`\n}\n\nfunc imageContentParts(text string, images []string) []chatContentPart {\n\tparts := make([]chatContentPart, 0, len(images)+1)\n\tif text != \"\" {\n\t\tparts = append(parts, chatContentPart{Type: \"text\", Text: text})\n\t}\n\tfor _, url := range images {\n\t\tparts = append(parts, chatContentPart{Type: \"image_url\", ImageURL: &chatImageURL{URL: url}})\n\t}\n\treturn parts\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "6edcf5adc3d3cba5263e44c79f3b577a789b65ea": {"t": 1785750696.774367, "n": 20535, "h": "23d6ef71e6401a14", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, MiniMax-M3, and\n// any other OpenAI-compatible endpoint are just config instances rather than\n// code. Each instance picks the wire shape from its base URL:\n// - api.deepseek.com → emits thinking.type=enabled (DeepSeek-flavor CoT) plus\n// reasoning_effort as a depth hint.\n// - api.minimaxi.com → emits thinking.type=adaptive|disabled (M3's binary\n// knob) instead of reasoning_effort, since M3 has no level scale.\n// - everything else (MiMo and other OpenAI-compatible gateways) uses the\n// vanilla reasoning_effort scale (low/medium/high).\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\n// defaultStreamIdleTimeout caps how long a started SSE stream may go without any\n// bytes before it's treated as a dropped connection. A half-open TCP connection\n// (e.g. a proxy switched mid-stream) sends no RST, so scanner.Scan() would block\n// forever; this turns that hang into a recoverable error. Generous on purpose —\n// live streams emit tokens/keepalives far more often. Stored per-client\n// (client.idleTimeout) so a test can shorten it without a shared global that\n// would race other streams' watchdogs.\nconst defaultStreamIdleTimeout = 120 * time.Second\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\teffort = strings.ToLower(strings.TrimSpace(effort))\n\tif effort == \"auto\" {\n\t\teffort = \"\"\n\t}\n\tprotocol, _ := cfg.Extra[\"reasoning_protocol\"].(string)\n\tprotocol = normalizeReasoningProtocol(protocol)\n\tdeepseek := protocol == \"deepseek\" || (protocol == \"\" && IsDeepSeek(cfg.BaseURL))\n\tminimax := protocol == \"\" && IsMiniMax(cfg.BaseURL)\n\tswitch {\n\tcase protocol == \"none\":\n\t\teffort = \"\"\n\tcase deepseek:\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\tcase minimax:\n\t\t// M3's knob is binary. The config effort layer normalises user input\n\t\t// to \"adaptive\", \"disabled\", or \"\" (== auto). We keep \"high\"/\"max\"\n\t\t// (legacy DeepSeek) and \"low\"/\"medium\" (Anthropic) out — config-level\n\t\t// NormalizeEffort remaps them to \"adaptive\" already, so anything\n\t\t// reaching here is expected to be one of: \"\", \"adaptive\", \"disabled\".\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\": // auto — leave empty so the wire emits thinking.type=adaptive\n\t\tcase \"adaptive\", \"disabled\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses MiniMax thinking; effort must be adaptive or disabled\", name)\n\t\t}\n\tcase effort != \"\":\n\t\t// Non-DeepSeek backends use OpenAI's reasoning_effort scale (low/medium/\n\t\t// high); \"max\" is a DeepSeek-ism MiMo et al. reject with 400, so clamp it\n\t\t// to the OpenAI ceiling and reject other values at boot, not at request time.\n\t\tswitch effort {\n\t\tcase \"max\":\n\t\t\teffort = \"high\"\n\t\tcase \"low\", \"medium\", \"high\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q: effort must be low, medium, or high\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\tminimax: minimax,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t\tidleTimeout: defaultStreamIdleTimeout,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\tminimax bool // true for api.minimaxi.com — emits MiniMax-M3's thinking knob instead of reasoning_effort\n\teffort string // reasoning_effort for OpenAI; thinking.type for MiniMax; \"\" = auto/provider default\n\tidleTimeout time.Duration // SSE stall watchdog window; defaultStreamIdleTimeout unless a test overrides\n\tauthed atomic.Bool // a request has succeeded — gate transient-401 retry\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc (c *client) sendOpts() provider.SendOptions {\n\treturn provider.SendOptions{\n\t\tProvider: c.name,\n\t\tKeyEnv: c.keyEnv,\n\t\tKeyPresent: c.apiKey != \"\",\n\t\tRetryAuth: c.authed.Load(),\n\t}\n}\n\nfunc normalizeReasoningProtocol(raw string) string {\n\tswitch strings.ToLower(strings.TrimSpace(raw)) {\n\tcase \"deepseek\", \"openai\", \"none\":\n\t\treturn strings.ToLower(strings.TrimSpace(raw))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn\n// allocates a buffer, marshals the request, and sends it — pooling avoids the\n// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is\n// provider-level (not global) so OpenAI and Anthropic don't compete.\nvar bufPool = sync.Pool{\n\tNew: func() any { return new(bytes.Buffer) },\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbuf := bufPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tif err := json.NewEncoder(buf).Encode(c.buildRequest(req)); err != nil {\n\t\tbufPool.Put(buf)\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\tbody := make([]byte, buf.Len())\n\tcopy(body, buf.Bytes())\n\tbufPool.Put(buf)\n\n\tnewReq := func(ctx context.Context) (*http.Request, error) {\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn httpReq, nil\n\t}\n\tresp, err := provider.SendWithRetry(ctx, c.http, c.sendOpts(), newReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tc.authed.Store(true)\n\n\tout := make(chan provider.Chunk)\n\tgo c.streamWithReconnect(ctx, resp, newReq, out)\n\treturn out, nil\n}\n\n// maxStreamReconnects bounds how many times a mid-stream connection drop is\n// replayed from scratch before the error is surfaced — each replay re-runs the\n// whole request (cheap under prompt caching, but not free).\nconst maxStreamReconnects = 3\n\n// streamWithReconnect drives readStream and, when the connection is cut before\n// any model output has been forwarded, replays the request rather than failing\n// the turn. Once a token (reasoning/text/tool-call) has been emitted, a replay\n// would duplicate output, so the error is surfaced instead.\nfunc (c *client) streamWithReconnect(ctx context.Context, resp *http.Response, newReq func(context.Context) (*http.Request, error), out chan<- provider.Chunk) {\n\tdefer close(out)\n\tfor attempt := 0; ; attempt++ {\n\t\temitted, err := c.readStream(ctx, resp, out)\n\t\tif err == nil {\n\t\t\treturn\n\t\t}\n\t\tif !provider.IsConnReset(err) {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tif emitted {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: &provider.StreamInterruptedError{Err: err}}\n\t\t\treturn\n\t\t}\n\t\tif attempt >= maxStreamReconnects {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tnext, rerr := provider.SendWithRetry(ctx, c.http, c.sendOpts(), newReq)\n\t\tif rerr != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: rerr}\n\t\t\treturn\n\t\t}\n\t\tresp = next\n\t}\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\t// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was\n\t\t// dropped on a cache-miss replay (\"reasoning_content … must be passed back\"),\n\t\t// so round it back — but only on the turn that carries the tool calls.\n\t\tif c.deepseek && m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\tcm.ReasoningContent = m.ReasoningContent\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tif m.Role != provider.RoleAssistant || len(cm.ToolCalls) == 0 || m.Content != \"\" {\n\t\t\tcontent := m.Content\n\t\t\tcm.Content = &content\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tswitch {\n\tcase c.deepseek:\n\t\t// DeepSeek's CoT is controlled by `thinking` (always on) plus\n\t\t// `reasoning_effort` for depth. We never disable thinking for DeepSeek.\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\tcase c.minimax:\n\t\t// M3 uses a single `thinking.type` field with two valid values:\n\t\t// \"adaptive\" (default, thinking on) and \"disabled\" (off). Reasoning\n\t\t// depth is not a knob on M3, so reasoning_effort is omitted entirely.\n\t\tt := c.effort\n\t\tif t == \"\" {\n\t\t\tt = \"adaptive\" // /effort auto == the M3 model default\n\t\t}\n\t\tout.Thinking = &thinkingMode{Type: t}\n\t\tout.ReasoningEffort = \"\"\n\t}\n\treturn out\n}\n\n// readStream parses one SSE response into chunks: text deltas stream live,\n// tool-call fragments accumulate by index and emit complete on [DONE], and a\n// ChunkToolCallStart fires the moment a call's name is known. It returns whether\n// any model output was forwarded (so the caller can decide a replay is safe) and\n// the first fatal error — a nil error means the stream reached [DONE].\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) (emitted bool, _ error) {\n\tdefer resp.Body.Close()\n\n\t// Close the response body when the context is canceled (user interrupt) or the\n\t// stream stalls past c.idleTimeout, so scanner.Scan() unblocks instead of\n\t// hanging on a half-open connection. done lets the watchdog exit on a normal\n\t// return — otherwise it outlives the call and blocks forever on a non-cancellable\n\t// context whose Done() is nil. The watchdog owns the timer; the read loop only\n\t// pings the buffered activity channel, so there's no Timer.Reset race.\n\tidleTimeout := c.idleTimeout\n\tif idleTimeout <= 0 { // zero-value client (constructed without New)\n\t\tidleTimeout = defaultStreamIdleTimeout\n\t}\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tactivity := make(chan struct{}, 1)\n\tvar stalled atomic.Bool\n\tgo func() {\n\t\tidle := time.NewTimer(idleTimeout)\n\t\tdefer idle.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\tresp.Body.Close()\n\t\t\t\treturn\n\t\t\tcase <-idle.C:\n\t\t\t\tstalled.Store(true)\n\t\t\t\tresp.Body.Close()\n\t\t\t\treturn\n\t\t\tcase <-activity:\n\t\t\t\tif !idle.Stop() {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase <-idle.C:\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tidle.Reset(idleTimeout)\n\t\t\tcase <-done:\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar sawDone bool\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tselect { // ping the idle watchdog; non-blocking so a full buffer is fine\n\t\tcase activity <- struct{}{}:\n\t\tdefault:\n\t\t}\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tsawDone = true\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: decode stream: %w\", c.name, err)\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif stalled.Load() {\n\t\treturn emitted, fmt.Errorf(\"%s: stream stalled — no data for %s, connection likely dropped\", c.name, idleTimeout)\n\t}\n\tif err := scanner.Err(); err != nil {\n\t\treturn emitted, fmt.Errorf(\"%s: read stream: %w\", c.name, err)\n\t}\n\t// A proxy that idle-closes with a clean FIN ends the scan with no error. Without\n\t// this check the turn would be committed as complete — including half-streamed\n\t// tool-call arguments, which then 400 on every replay (#3953).\n\tif !sawDone && lastFinishReason == \"\" {\n\t\treturn emitted, fmt.Errorf(\"%s: stream ended before completion: %w\", c.name, io.ErrUnexpectedEOF)\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n\treturn emitted, nil\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always present (never omitted): DeepSeek's strict deserializer\n\t// rejects a message missing the field. A pure tool_calls assistant turn\n\t// serializes as null (OpenAI-spec, and what strict clones expect); every\n\t// other role/message serializes as a string, empty included — null is\n\t// rejected by some backends for a tool message.\n\tContent *string `json:\"content\"`\n\tReasoningContent string `json:\"reasoning_content,omitempty\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "0263ee9b3aedd87c38a8f10269811fd4035bc8bd": {"t": 1785750698.0430155, "n": 20246, "h": "b51fa55f1821c2b9", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, MiniMax-M3, and\n// any other OpenAI-compatible endpoint are just config instances rather than\n// code. Each instance picks the wire shape from its base URL:\n// - api.deepseek.com → emits thinking.type=enabled (DeepSeek-flavor CoT) plus\n// reasoning_effort as a depth hint.\n// - api.minimaxi.com → emits thinking.type=adaptive|disabled (M3's binary\n// knob) instead of reasoning_effort, since M3 has no level scale.\n// - everything else (MiMo and other OpenAI-compatible gateways) uses the\n// vanilla reasoning_effort scale (low/medium/high).\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\n// defaultStreamIdleTimeout caps how long a started SSE stream may go without any\n// bytes before it's treated as a dropped connection. A half-open TCP connection\n// (e.g. a proxy switched mid-stream) sends no RST, so scanner.Scan() would block\n// forever; this turns that hang into a recoverable error. Generous on purpose —\n// live streams emit tokens/keepalives far more often. Stored per-client\n// (client.idleTimeout) so a test can shorten it without a shared global that\n// would race other streams' watchdogs.\nconst defaultStreamIdleTimeout = 120 * time.Second\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\teffort = strings.ToLower(strings.TrimSpace(effort))\n\tif effort == \"auto\" {\n\t\teffort = \"\"\n\t}\n\tprotocol, _ := cfg.Extra[\"reasoning_protocol\"].(string)\n\tprotocol = normalizeReasoningProtocol(protocol)\n\tdeepseek := protocol == \"deepseek\" || (protocol == \"\" && IsDeepSeek(cfg.BaseURL))\n\tminimax := protocol == \"\" && IsMiniMax(cfg.BaseURL)\n\tswitch {\n\tcase protocol == \"none\":\n\t\teffort = \"\"\n\tcase deepseek:\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\tcase minimax:\n\t\t// M3's knob is binary. The config effort layer normalises user input\n\t\t// to \"adaptive\", \"disabled\", or \"\" (== auto). We keep \"high\"/\"max\"\n\t\t// (legacy DeepSeek) and \"low\"/\"medium\" (Anthropic) out — config-level\n\t\t// NormalizeEffort remaps them to \"adaptive\" already, so anything\n\t\t// reaching here is expected to be one of: \"\", \"adaptive\", \"disabled\".\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\": // auto — leave empty so the wire emits thinking.type=adaptive\n\t\tcase \"adaptive\", \"disabled\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses MiniMax thinking; effort must be adaptive or disabled\", name)\n\t\t}\n\tcase effort != \"\":\n\t\t// Non-DeepSeek backends use OpenAI's reasoning_effort scale (low/medium/\n\t\t// high); \"max\" is a DeepSeek-ism MiMo et al. reject with 400, so clamp it\n\t\t// to the OpenAI ceiling and reject other values at boot, not at request time.\n\t\tswitch effort {\n\t\tcase \"max\":\n\t\t\teffort = \"high\"\n\t\tcase \"low\", \"medium\", \"high\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q: effort must be low, medium, or high\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\tminimax: minimax,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t\tidleTimeout: defaultStreamIdleTimeout,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\tminimax bool // true for api.minimaxi.com — emits MiniMax-M3's thinking knob instead of reasoning_effort\n\teffort string // reasoning_effort for OpenAI; thinking.type for MiniMax; \"\" = auto/provider default\n\tidleTimeout time.Duration // SSE stall watchdog window; defaultStreamIdleTimeout unless a test overrides\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc normalizeReasoningProtocol(raw string) string {\n\tswitch strings.ToLower(strings.TrimSpace(raw)) {\n\tcase \"deepseek\", \"openai\", \"none\":\n\t\treturn strings.ToLower(strings.TrimSpace(raw))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn\n// allocates a buffer, marshals the request, and sends it — pooling avoids the\n// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is\n// provider-level (not global) so OpenAI and Anthropic don't compete.\nvar bufPool = sync.Pool{\n\tNew: func() any { return new(bytes.Buffer) },\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbuf := bufPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tif err := json.NewEncoder(buf).Encode(c.buildRequest(req)); err != nil {\n\t\tbufPool.Put(buf)\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\tbody := make([]byte, buf.Len())\n\tcopy(body, buf.Bytes())\n\tbufPool.Put(buf)\n\n\tnewReq := func(ctx context.Context) (*http.Request, error) {\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn httpReq, nil\n\t}\n\tresp, err := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.streamWithReconnect(ctx, resp, newReq, out)\n\treturn out, nil\n}\n\n// maxStreamReconnects bounds how many times a mid-stream connection drop is\n// replayed from scratch before the error is surfaced — each replay re-runs the\n// whole request (cheap under prompt caching, but not free).\nconst maxStreamReconnects = 3\n\n// streamWithReconnect drives readStream and, when the connection is cut before\n// any model output has been forwarded, replays the request rather than failing\n// the turn. Once a token (reasoning/text/tool-call) has been emitted, a replay\n// would duplicate output, so the error is surfaced instead.\nfunc (c *client) streamWithReconnect(ctx context.Context, resp *http.Response, newReq func(context.Context) (*http.Request, error), out chan<- provider.Chunk) {\n\tdefer close(out)\n\tfor attempt := 0; ; attempt++ {\n\t\temitted, err := c.readStream(ctx, resp, out)\n\t\tif err == nil {\n\t\t\treturn\n\t\t}\n\t\tif !provider.IsConnReset(err) {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tif emitted {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: &provider.StreamInterruptedError{Err: err}}\n\t\t\treturn\n\t\t}\n\t\tif attempt >= maxStreamReconnects {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tnext, rerr := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\t\tif rerr != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: rerr}\n\t\t\treturn\n\t\t}\n\t\tresp = next\n\t}\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\t// DeepSeek thinking mode 400s a tool_calls turn whose reasoning_content was\n\t\t// dropped on a cache-miss replay (\"reasoning_content … must be passed back\"),\n\t\t// so round it back — but only on the turn that carries the tool calls.\n\t\tif c.deepseek && m.Role == provider.RoleAssistant && len(m.ToolCalls) > 0 {\n\t\t\tcm.ReasoningContent = m.ReasoningContent\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tif m.Role != provider.RoleAssistant || len(cm.ToolCalls) == 0 || m.Content != \"\" {\n\t\t\tcontent := m.Content\n\t\t\tcm.Content = &content\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tswitch {\n\tcase c.deepseek:\n\t\t// DeepSeek's CoT is controlled by `thinking` (always on) plus\n\t\t// `reasoning_effort` for depth. We never disable thinking for DeepSeek.\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\tcase c.minimax:\n\t\t// M3 uses a single `thinking.type` field with two valid values:\n\t\t// \"adaptive\" (default, thinking on) and \"disabled\" (off). Reasoning\n\t\t// depth is not a knob on M3, so reasoning_effort is omitted entirely.\n\t\tt := c.effort\n\t\tif t == \"\" {\n\t\t\tt = \"adaptive\" // /effort auto == the M3 model default\n\t\t}\n\t\tout.Thinking = &thinkingMode{Type: t}\n\t\tout.ReasoningEffort = \"\"\n\t}\n\treturn out\n}\n\n// readStream parses one SSE response into chunks: text deltas stream live,\n// tool-call fragments accumulate by index and emit complete on [DONE], and a\n// ChunkToolCallStart fires the moment a call's name is known. It returns whether\n// any model output was forwarded (so the caller can decide a replay is safe) and\n// the first fatal error — a nil error means the stream reached [DONE].\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) (emitted bool, _ error) {\n\tdefer resp.Body.Close()\n\n\t// Close the response body when the context is canceled (user interrupt) or the\n\t// stream stalls past c.idleTimeout, so scanner.Scan() unblocks instead of\n\t// hanging on a half-open connection. done lets the watchdog exit on a normal\n\t// return — otherwise it outlives the call and blocks forever on a non-cancellable\n\t// context whose Done() is nil. The watchdog owns the timer; the read loop only\n\t// pings the buffered activity channel, so there's no Timer.Reset race.\n\tidleTimeout := c.idleTimeout\n\tif idleTimeout <= 0 { // zero-value client (constructed without New)\n\t\tidleTimeout = defaultStreamIdleTimeout\n\t}\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tactivity := make(chan struct{}, 1)\n\tvar stalled atomic.Bool\n\tgo func() {\n\t\tidle := time.NewTimer(idleTimeout)\n\t\tdefer idle.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\tresp.Body.Close()\n\t\t\t\treturn\n\t\t\tcase <-idle.C:\n\t\t\t\tstalled.Store(true)\n\t\t\t\tresp.Body.Close()\n\t\t\t\treturn\n\t\t\tcase <-activity:\n\t\t\t\tif !idle.Stop() {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase <-idle.C:\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tidle.Reset(idleTimeout)\n\t\t\tcase <-done:\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar sawDone bool\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tselect { // ping the idle watchdog; non-blocking so a full buffer is fine\n\t\tcase activity <- struct{}{}:\n\t\tdefault:\n\t\t}\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tsawDone = true\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: decode stream: %w\", c.name, err)\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif stalled.Load() {\n\t\treturn emitted, fmt.Errorf(\"%s: stream stalled — no data for %s, connection likely dropped\", c.name, idleTimeout)\n\t}\n\tif err := scanner.Err(); err != nil {\n\t\treturn emitted, fmt.Errorf(\"%s: read stream: %w\", c.name, err)\n\t}\n\t// A proxy that idle-closes with a clean FIN ends the scan with no error. Without\n\t// this check the turn would be committed as complete — including half-streamed\n\t// tool-call arguments, which then 400 on every replay (#3953).\n\tif !sawDone && lastFinishReason == \"\" {\n\t\treturn emitted, fmt.Errorf(\"%s: stream ended before completion: %w\", c.name, io.ErrUnexpectedEOF)\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n\treturn emitted, nil\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always present (never omitted): DeepSeek's strict deserializer\n\t// rejects a message missing the field. A pure tool_calls assistant turn\n\t// serializes as null (OpenAI-spec, and what strict clones expect); every\n\t// other role/message serializes as a string, empty included — null is\n\t// rejected by some backends for a tool message.\n\tContent *string `json:\"content\"`\n\tReasoningContent string `json:\"reasoning_content,omitempty\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "adde2d3e2354161b0e3edb4ea16312d3779f83e6": {"t": 1785750699.2604406, "n": 20379, "h": "ad1d7e6e99b2fddf", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, MiniMax-M3, and\n// any other OpenAI-compatible endpoint are just config instances rather than\n// code. Each instance picks the wire shape from its base URL:\n// - api.deepseek.com → emits thinking.type=enabled (DeepSeek-flavor CoT) plus\n// reasoning_effort as a depth hint.\n// - api.minimaxi.com → emits thinking.type=adaptive|disabled (M3's binary\n// knob) instead of reasoning_effort, since M3 has no level scale.\n// - everything else (MiMo and other OpenAI-compatible gateways) uses the\n// vanilla reasoning_effort scale (low/medium/high).\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\n// defaultStreamIdleTimeout caps how long a started SSE stream may go without any\n// bytes before it's treated as a dropped connection. A half-open TCP connection\n// (e.g. a proxy switched mid-stream) sends no RST, so scanner.Scan() would block\n// forever; this turns that hang into a recoverable error. Generous on purpose —\n// live streams emit tokens/keepalives far more often. Stored per-client\n// (client.idleTimeout) so a test can shorten it without a shared global that\n// would race other streams' watchdogs.\nconst defaultStreamIdleTimeout = 120 * time.Second\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\teffort = strings.ToLower(strings.TrimSpace(effort))\n\tif effort == \"auto\" {\n\t\teffort = \"\"\n\t}\n\tprotocol, _ := cfg.Extra[\"reasoning_protocol\"].(string)\n\tprotocol = normalizeReasoningProtocol(protocol)\n\tdeepseek := protocol == \"deepseek\" || (protocol == \"\" && IsDeepSeek(cfg.BaseURL))\n\tminimax := protocol == \"\" && IsMiniMax(cfg.BaseURL)\n\tswitch {\n\tcase protocol == \"none\":\n\t\teffort = \"\"\n\tcase deepseek:\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\tcase minimax:\n\t\t// M3's knob is binary. The config effort layer normalises user input\n\t\t// to \"adaptive\", \"disabled\", or \"\" (== auto). We keep \"high\"/\"max\"\n\t\t// (legacy DeepSeek) and \"low\"/\"medium\" (Anthropic) out — config-level\n\t\t// NormalizeEffort remaps them to \"adaptive\" already, so anything\n\t\t// reaching here is expected to be one of: \"\", \"adaptive\", \"disabled\".\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\": // auto — leave empty so the wire emits thinking.type=adaptive\n\t\tcase \"adaptive\", \"disabled\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses MiniMax thinking; effort must be adaptive or disabled\", name)\n\t\t}\n\tcase effort != \"\":\n\t\t// Non-DeepSeek backends use OpenAI's reasoning_effort scale (low/medium/\n\t\t// high); \"max\" is a DeepSeek-ism MiMo et al. reject with 400, so clamp it\n\t\t// to the OpenAI ceiling and reject other values at boot, not at request time.\n\t\tswitch effort {\n\t\tcase \"max\":\n\t\t\teffort = \"high\"\n\t\tcase \"low\", \"medium\", \"high\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q: effort must be low, medium, or high\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\tminimax: minimax,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t\tidleTimeout: defaultStreamIdleTimeout,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\tminimax bool // true for api.minimaxi.com — emits MiniMax-M3's thinking knob instead of reasoning_effort\n\teffort string // reasoning_effort for OpenAI; thinking.type for MiniMax; \"\" = auto/provider default\n\tidleTimeout time.Duration // SSE stall watchdog window; defaultStreamIdleTimeout unless a test overrides\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc normalizeReasoningProtocol(raw string) string {\n\tswitch strings.ToLower(strings.TrimSpace(raw)) {\n\tcase \"deepseek\", \"openai\", \"none\":\n\t\treturn strings.ToLower(strings.TrimSpace(raw))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn\n// allocates a buffer, marshals the request, and sends it — pooling avoids the\n// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is\n// provider-level (not global) so OpenAI and Anthropic don't compete.\nvar bufPool = sync.Pool{\n\tNew: func() any { return new(bytes.Buffer) },\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbuf := bufPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tif err := json.NewEncoder(buf).Encode(c.buildRequest(req)); err != nil {\n\t\tbufPool.Put(buf)\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\tbody := make([]byte, buf.Len())\n\tcopy(body, buf.Bytes())\n\tbufPool.Put(buf)\n\n\tnewReq := func(ctx context.Context) (*http.Request, error) {\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn httpReq, nil\n\t}\n\tresp, err := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.streamWithReconnect(ctx, resp, newReq, out)\n\treturn out, nil\n}\n\n// maxStreamReconnects bounds how many times a mid-stream connection drop is\n// replayed from scratch before the error is surfaced — each replay re-runs the\n// whole request (cheap under prompt caching, but not free).\nconst maxStreamReconnects = 3\n\n// streamWithReconnect drives readStream and, when the connection is cut before\n// any model output has been forwarded, replays the request rather than failing\n// the turn. Once a token (reasoning/text/tool-call) has been emitted, a replay\n// would duplicate output, so the error is surfaced instead.\nfunc (c *client) streamWithReconnect(ctx context.Context, resp *http.Response, newReq func(context.Context) (*http.Request, error), out chan<- provider.Chunk) {\n\tdefer close(out)\n\tfor attempt := 0; ; attempt++ {\n\t\temitted, err := c.readStream(ctx, resp, out)\n\t\tif err == nil {\n\t\t\treturn\n\t\t}\n\t\tif !provider.IsConnReset(err) {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tif emitted {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: &provider.StreamInterruptedError{Err: err}}\n\t\t\treturn\n\t\t}\n\t\tif attempt >= maxStreamReconnects {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tnext, rerr := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\t\tif rerr != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: rerr}\n\t\t\treturn\n\t\t}\n\t\tresp = next\n\t}\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek counts re-sent reasoning as billable prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain); MiMo accepts\n\t\t// it but does not require it (verified empirically: multi-turn tool-call\n\t\t// sessions work fine without it, saving ~18 tokens/turn). The session\n\t\t// still keeps it (for display/archive); we just don't pay to re-upload it.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tif m.Role != provider.RoleAssistant || len(cm.ToolCalls) == 0 || m.Content != \"\" {\n\t\t\tcontent := m.Content\n\t\t\tcm.Content = &content\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tswitch {\n\tcase c.deepseek:\n\t\t// DeepSeek's CoT is controlled by `thinking` (always on) plus\n\t\t// `reasoning_effort` for depth. We never disable thinking for DeepSeek.\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\tcase c.minimax:\n\t\t// M3 uses a single `thinking.type` field with two valid values:\n\t\t// \"adaptive\" (default, thinking on) and \"disabled\" (off). Reasoning\n\t\t// depth is not a knob on M3, so reasoning_effort is omitted entirely.\n\t\tt := c.effort\n\t\tif t == \"\" {\n\t\t\tt = \"adaptive\" // /effort auto == the M3 model default\n\t\t}\n\t\tout.Thinking = &thinkingMode{Type: t}\n\t\tout.ReasoningEffort = \"\"\n\t}\n\treturn out\n}\n\n// readStream parses one SSE response into chunks: text deltas stream live,\n// tool-call fragments accumulate by index and emit complete on [DONE], and a\n// ChunkToolCallStart fires the moment a call's name is known. It returns whether\n// any model output was forwarded (so the caller can decide a replay is safe) and\n// the first fatal error — a nil error means the stream reached [DONE].\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) (emitted bool, _ error) {\n\tdefer resp.Body.Close()\n\n\t// Close the response body when the context is canceled (user interrupt) or the\n\t// stream stalls past c.idleTimeout, so scanner.Scan() unblocks instead of\n\t// hanging on a half-open connection. done lets the watchdog exit on a normal\n\t// return — otherwise it outlives the call and blocks forever on a non-cancellable\n\t// context whose Done() is nil. The watchdog owns the timer; the read loop only\n\t// pings the buffered activity channel, so there's no Timer.Reset race.\n\tidleTimeout := c.idleTimeout\n\tif idleTimeout <= 0 { // zero-value client (constructed without New)\n\t\tidleTimeout = defaultStreamIdleTimeout\n\t}\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tactivity := make(chan struct{}, 1)\n\tvar stalled atomic.Bool\n\tgo func() {\n\t\tidle := time.NewTimer(idleTimeout)\n\t\tdefer idle.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\tresp.Body.Close()\n\t\t\t\treturn\n\t\t\tcase <-idle.C:\n\t\t\t\tstalled.Store(true)\n\t\t\t\tresp.Body.Close()\n\t\t\t\treturn\n\t\t\tcase <-activity:\n\t\t\t\tif !idle.Stop() {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase <-idle.C:\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tidle.Reset(idleTimeout)\n\t\t\tcase <-done:\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar sawDone bool\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tselect { // ping the idle watchdog; non-blocking so a full buffer is fine\n\t\tcase activity <- struct{}{}:\n\t\tdefault:\n\t\t}\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tsawDone = true\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: decode stream: %w\", c.name, err)\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif stalled.Load() {\n\t\treturn emitted, fmt.Errorf(\"%s: stream stalled — no data for %s, connection likely dropped\", c.name, idleTimeout)\n\t}\n\tif err := scanner.Err(); err != nil {\n\t\treturn emitted, fmt.Errorf(\"%s: read stream: %w\", c.name, err)\n\t}\n\t// A proxy that idle-closes with a clean FIN ends the scan with no error. Without\n\t// this check the turn would be committed as complete — including half-streamed\n\t// tool-call arguments, which then 400 on every replay (#3953).\n\tif !sawDone && lastFinishReason == \"\" {\n\t\treturn emitted, fmt.Errorf(\"%s: stream ended before completion: %w\", c.name, io.ErrUnexpectedEOF)\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n\treturn emitted, nil\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always present (never omitted): DeepSeek's strict deserializer\n\t// rejects a message missing the field. A pure tool_calls assistant turn\n\t// serializes as null (OpenAI-spec, and what strict clones expect); every\n\t// other role/message serializes as a string, empty included — null is\n\t// rejected by some backends for a tool message.\n\tContent *string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "9976e6bae12a8516608655ee0872afbd5d73c93e": {"t": 1785750700.4090235, "n": 19964, "h": "3948aa2aa103f960", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, MiniMax-M3, and\n// any other OpenAI-compatible endpoint are just config instances rather than\n// code. Each instance picks the wire shape from its base URL:\n// - api.deepseek.com → emits thinking.type=enabled (DeepSeek-flavor CoT) plus\n// reasoning_effort as a depth hint.\n// - api.minimaxi.com → emits thinking.type=adaptive|disabled (M3's binary\n// knob) instead of reasoning_effort, since M3 has no level scale.\n// - everything else (MiMo and other OpenAI-compatible gateways) uses the\n// vanilla reasoning_effort scale (low/medium/high).\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"sync/atomic\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\n// defaultStreamIdleTimeout caps how long a started SSE stream may go without any\n// bytes before it's treated as a dropped connection. A half-open TCP connection\n// (e.g. a proxy switched mid-stream) sends no RST, so scanner.Scan() would block\n// forever; this turns that hang into a recoverable error. Generous on purpose —\n// live streams emit tokens/keepalives far more often. Stored per-client\n// (client.idleTimeout) so a test can shorten it without a shared global that\n// would race other streams' watchdogs.\nconst defaultStreamIdleTimeout = 120 * time.Second\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\teffort = strings.ToLower(strings.TrimSpace(effort))\n\tif effort == \"auto\" {\n\t\teffort = \"\"\n\t}\n\tprotocol, _ := cfg.Extra[\"reasoning_protocol\"].(string)\n\tprotocol = normalizeReasoningProtocol(protocol)\n\tdeepseek := protocol == \"deepseek\" || (protocol == \"\" && IsDeepSeek(cfg.BaseURL))\n\tminimax := protocol == \"\" && IsMiniMax(cfg.BaseURL)\n\tswitch {\n\tcase protocol == \"none\":\n\t\teffort = \"\"\n\tcase deepseek:\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\tcase minimax:\n\t\t// M3's knob is binary. The config effort layer normalises user input\n\t\t// to \"adaptive\", \"disabled\", or \"\" (== auto). We keep \"high\"/\"max\"\n\t\t// (legacy DeepSeek) and \"low\"/\"medium\" (Anthropic) out — config-level\n\t\t// NormalizeEffort remaps them to \"adaptive\" already, so anything\n\t\t// reaching here is expected to be one of: \"\", \"adaptive\", \"disabled\".\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\": // auto — leave empty so the wire emits thinking.type=adaptive\n\t\tcase \"adaptive\", \"disabled\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses MiniMax thinking; effort must be adaptive or disabled\", name)\n\t\t}\n\tcase effort != \"\":\n\t\t// Non-DeepSeek backends use OpenAI's reasoning_effort scale (low/medium/\n\t\t// high); \"max\" is a DeepSeek-ism MiMo et al. reject with 400, so clamp it\n\t\t// to the OpenAI ceiling and reject other values at boot, not at request time.\n\t\tswitch effort {\n\t\tcase \"max\":\n\t\t\teffort = \"high\"\n\t\tcase \"low\", \"medium\", \"high\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q: effort must be low, medium, or high\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\tminimax: minimax,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t\tidleTimeout: defaultStreamIdleTimeout,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\tminimax bool // true for api.minimaxi.com — emits MiniMax-M3's thinking knob instead of reasoning_effort\n\teffort string // reasoning_effort for OpenAI; thinking.type for MiniMax; \"\" = auto/provider default\n\tidleTimeout time.Duration // SSE stall watchdog window; defaultStreamIdleTimeout unless a test overrides\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc normalizeReasoningProtocol(raw string) string {\n\tswitch strings.ToLower(strings.TrimSpace(raw)) {\n\tcase \"deepseek\", \"openai\", \"none\":\n\t\treturn strings.ToLower(strings.TrimSpace(raw))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn\n// allocates a buffer, marshals the request, and sends it — pooling avoids the\n// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is\n// provider-level (not global) so OpenAI and Anthropic don't compete.\nvar bufPool = sync.Pool{\n\tNew: func() any { return new(bytes.Buffer) },\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbuf := bufPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tif err := json.NewEncoder(buf).Encode(c.buildRequest(req)); err != nil {\n\t\tbufPool.Put(buf)\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\tbody := make([]byte, buf.Len())\n\tcopy(body, buf.Bytes())\n\tbufPool.Put(buf)\n\n\tnewReq := func(ctx context.Context) (*http.Request, error) {\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn httpReq, nil\n\t}\n\tresp, err := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.streamWithReconnect(ctx, resp, newReq, out)\n\treturn out, nil\n}\n\n// maxStreamReconnects bounds how many times a mid-stream connection drop is\n// replayed from scratch before the error is surfaced — each replay re-runs the\n// whole request (cheap under prompt caching, but not free).\nconst maxStreamReconnects = 3\n\n// streamWithReconnect drives readStream and, when the connection is cut before\n// any model output has been forwarded, replays the request rather than failing\n// the turn. Once a token (reasoning/text/tool-call) has been emitted, a replay\n// would duplicate output, so the error is surfaced instead.\nfunc (c *client) streamWithReconnect(ctx context.Context, resp *http.Response, newReq func(context.Context) (*http.Request, error), out chan<- provider.Chunk) {\n\tdefer close(out)\n\tfor attempt := 0; ; attempt++ {\n\t\temitted, err := c.readStream(ctx, resp, out)\n\t\tif err == nil {\n\t\t\treturn\n\t\t}\n\t\tif !provider.IsConnReset(err) {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tif emitted {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: &provider.StreamInterruptedError{Err: err}}\n\t\t\treturn\n\t\t}\n\t\tif attempt >= maxStreamReconnects {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tnext, rerr := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\t\tif rerr != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: rerr}\n\t\t\treturn\n\t\t}\n\t\tresp = next\n\t}\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek counts re-sent reasoning as billable prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain); MiMo accepts\n\t\t// it but does not require it (verified empirically: multi-turn tool-call\n\t\t// sessions work fine without it, saving ~18 tokens/turn). The session\n\t\t// still keeps it (for display/archive); we just don't pay to re-upload it.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tif m.Role != provider.RoleAssistant || len(cm.ToolCalls) == 0 || m.Content != \"\" {\n\t\t\tcontent := m.Content\n\t\t\tcm.Content = &content\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tswitch {\n\tcase c.deepseek:\n\t\t// DeepSeek's CoT is controlled by `thinking` (always on) plus\n\t\t// `reasoning_effort` for depth. We never disable thinking for DeepSeek.\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\tcase c.minimax:\n\t\t// M3 uses a single `thinking.type` field with two valid values:\n\t\t// \"adaptive\" (default, thinking on) and \"disabled\" (off). Reasoning\n\t\t// depth is not a knob on M3, so reasoning_effort is omitted entirely.\n\t\tt := c.effort\n\t\tif t == \"\" {\n\t\t\tt = \"adaptive\" // /effort auto == the M3 model default\n\t\t}\n\t\tout.Thinking = &thinkingMode{Type: t}\n\t\tout.ReasoningEffort = \"\"\n\t}\n\treturn out\n}\n\n// readStream parses one SSE response into chunks: text deltas stream live,\n// tool-call fragments accumulate by index and emit complete on [DONE], and a\n// ChunkToolCallStart fires the moment a call's name is known. It returns whether\n// any model output was forwarded (so the caller can decide a replay is safe) and\n// the first fatal error — a nil error means the stream reached [DONE].\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) (emitted bool, _ error) {\n\tdefer resp.Body.Close()\n\n\t// Close the response body when the context is canceled (user interrupt) or the\n\t// stream stalls past c.idleTimeout, so scanner.Scan() unblocks instead of\n\t// hanging on a half-open connection. done lets the watchdog exit on a normal\n\t// return — otherwise it outlives the call and blocks forever on a non-cancellable\n\t// context whose Done() is nil. The watchdog owns the timer; the read loop only\n\t// pings the buffered activity channel, so there's no Timer.Reset race.\n\tidleTimeout := c.idleTimeout\n\tif idleTimeout <= 0 { // zero-value client (constructed without New)\n\t\tidleTimeout = defaultStreamIdleTimeout\n\t}\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tactivity := make(chan struct{}, 1)\n\tvar stalled atomic.Bool\n\tgo func() {\n\t\tidle := time.NewTimer(idleTimeout)\n\t\tdefer idle.Stop()\n\t\tfor {\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\tresp.Body.Close()\n\t\t\t\treturn\n\t\t\tcase <-idle.C:\n\t\t\t\tstalled.Store(true)\n\t\t\t\tresp.Body.Close()\n\t\t\t\treturn\n\t\t\tcase <-activity:\n\t\t\t\tif !idle.Stop() {\n\t\t\t\t\tselect {\n\t\t\t\t\tcase <-idle.C:\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tidle.Reset(idleTimeout)\n\t\t\tcase <-done:\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tselect { // ping the idle watchdog; non-blocking so a full buffer is fine\n\t\tcase activity <- struct{}{}:\n\t\tdefault:\n\t\t}\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: decode stream: %w\", c.name, err)\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif stalled.Load() {\n\t\treturn emitted, fmt.Errorf(\"%s: stream stalled — no data for %s, connection likely dropped\", c.name, idleTimeout)\n\t}\n\tif err := scanner.Err(); err != nil {\n\t\treturn emitted, fmt.Errorf(\"%s: read stream: %w\", c.name, err)\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n\treturn emitted, nil\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always present (never omitted): DeepSeek's strict deserializer\n\t// rejects a message missing the field. A pure tool_calls assistant turn\n\t// serializes as null (OpenAI-spec, and what strict clones expect); every\n\t// other role/message serializes as a string, empty included — null is\n\t// rejected by some backends for a tool message.\n\tContent *string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "8b2028e82427dc4925bc61c793c639116a44e8ee": {"t": 1785750701.5238574, "n": 18227, "h": "fa9dc007cc0fca6d", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, MiniMax-M3, and\n// any other OpenAI-compatible endpoint are just config instances rather than\n// code. Each instance picks the wire shape from its base URL:\n// - api.deepseek.com → emits thinking.type=enabled (DeepSeek-flavor CoT) plus\n// reasoning_effort as a depth hint.\n// - api.minimaxi.com → emits thinking.type=adaptive|disabled (M3's binary\n// knob) instead of reasoning_effort, since M3 has no level scale.\n// - everything else (MiMo and other OpenAI-compatible gateways) uses the\n// vanilla reasoning_effort scale (low/medium/high).\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\teffort = strings.ToLower(strings.TrimSpace(effort))\n\tif effort == \"auto\" {\n\t\teffort = \"\"\n\t}\n\tprotocol, _ := cfg.Extra[\"reasoning_protocol\"].(string)\n\tprotocol = normalizeReasoningProtocol(protocol)\n\tdeepseek := protocol == \"deepseek\" || (protocol == \"\" && IsDeepSeek(cfg.BaseURL))\n\tminimax := protocol == \"\" && IsMiniMax(cfg.BaseURL)\n\tswitch {\n\tcase protocol == \"none\":\n\t\teffort = \"\"\n\tcase deepseek:\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\tcase minimax:\n\t\t// M3's knob is binary. The config effort layer normalises user input\n\t\t// to \"adaptive\", \"disabled\", or \"\" (== auto). We keep \"high\"/\"max\"\n\t\t// (legacy DeepSeek) and \"low\"/\"medium\" (Anthropic) out — config-level\n\t\t// NormalizeEffort remaps them to \"adaptive\" already, so anything\n\t\t// reaching here is expected to be one of: \"\", \"adaptive\", \"disabled\".\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\": // auto — leave empty so the wire emits thinking.type=adaptive\n\t\tcase \"adaptive\", \"disabled\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses MiniMax thinking; effort must be adaptive or disabled\", name)\n\t\t}\n\tcase effort != \"\":\n\t\t// Non-DeepSeek backends use OpenAI's reasoning_effort scale (low/medium/\n\t\t// high); \"max\" is a DeepSeek-ism MiMo et al. reject with 400, so clamp it\n\t\t// to the OpenAI ceiling and reject other values at boot, not at request time.\n\t\tswitch effort {\n\t\tcase \"max\":\n\t\t\teffort = \"high\"\n\t\tcase \"low\", \"medium\", \"high\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q: effort must be low, medium, or high\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\tminimax: minimax,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\tminimax bool // true for api.minimaxi.com — emits MiniMax-M3's thinking knob instead of reasoning_effort\n\teffort string // reasoning_effort for OpenAI; thinking.type for MiniMax; \"\" = auto/provider default\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc normalizeReasoningProtocol(raw string) string {\n\tswitch strings.ToLower(strings.TrimSpace(raw)) {\n\tcase \"deepseek\", \"openai\", \"none\":\n\t\treturn strings.ToLower(strings.TrimSpace(raw))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn\n// allocates a buffer, marshals the request, and sends it — pooling avoids the\n// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is\n// provider-level (not global) so OpenAI and Anthropic don't compete.\nvar bufPool = sync.Pool{\n\tNew: func() any { return new(bytes.Buffer) },\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbuf := bufPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tif err := json.NewEncoder(buf).Encode(c.buildRequest(req)); err != nil {\n\t\tbufPool.Put(buf)\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\tbody := make([]byte, buf.Len())\n\tcopy(body, buf.Bytes())\n\tbufPool.Put(buf)\n\n\tnewReq := func(ctx context.Context) (*http.Request, error) {\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn httpReq, nil\n\t}\n\tresp, err := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.streamWithReconnect(ctx, resp, newReq, out)\n\treturn out, nil\n}\n\n// maxStreamReconnects bounds how many times a mid-stream connection drop is\n// replayed from scratch before the error is surfaced — each replay re-runs the\n// whole request (cheap under prompt caching, but not free).\nconst maxStreamReconnects = 3\n\n// streamWithReconnect drives readStream and, when the connection is cut before\n// any model output has been forwarded, replays the request rather than failing\n// the turn. Once a token (reasoning/text/tool-call) has been emitted, a replay\n// would duplicate output, so the error is surfaced instead.\nfunc (c *client) streamWithReconnect(ctx context.Context, resp *http.Response, newReq func(context.Context) (*http.Request, error), out chan<- provider.Chunk) {\n\tdefer close(out)\n\tfor attempt := 0; ; attempt++ {\n\t\temitted, err := c.readStream(ctx, resp, out)\n\t\tif err == nil {\n\t\t\treturn\n\t\t}\n\t\tif !provider.IsConnReset(err) {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tif emitted {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: &provider.StreamInterruptedError{Err: err}}\n\t\t\treturn\n\t\t}\n\t\tif attempt >= maxStreamReconnects {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tnext, rerr := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\t\tif rerr != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: rerr}\n\t\t\treturn\n\t\t}\n\t\tresp = next\n\t}\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek counts re-sent reasoning as billable prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain); MiMo accepts\n\t\t// it but does not require it (verified empirically: multi-turn tool-call\n\t\t// sessions work fine without it, saving ~18 tokens/turn). The session\n\t\t// still keeps it (for display/archive); we just don't pay to re-upload it.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tif m.Role != provider.RoleAssistant || len(cm.ToolCalls) == 0 || m.Content != \"\" {\n\t\t\tcontent := m.Content\n\t\t\tcm.Content = &content\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tswitch {\n\tcase c.deepseek:\n\t\t// DeepSeek's CoT is controlled by `thinking` (always on) plus\n\t\t// `reasoning_effort` for depth. We never disable thinking for DeepSeek.\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\tcase c.minimax:\n\t\t// M3 uses a single `thinking.type` field with two valid values:\n\t\t// \"adaptive\" (default, thinking on) and \"disabled\" (off). Reasoning\n\t\t// depth is not a knob on M3, so reasoning_effort is omitted entirely.\n\t\tt := c.effort\n\t\tif t == \"\" {\n\t\t\tt = \"adaptive\" // /effort auto == the M3 model default\n\t\t}\n\t\tout.Thinking = &thinkingMode{Type: t}\n\t\tout.ReasoningEffort = \"\"\n\t}\n\treturn out\n}\n\n// readStream parses one SSE response into chunks: text deltas stream live,\n// tool-call fragments accumulate by index and emit complete on [DONE], and a\n// ChunkToolCallStart fires the moment a call's name is known. It returns whether\n// any model output was forwarded (so the caller can decide a replay is safe) and\n// the first fatal error — a nil error means the stream reached [DONE].\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) (emitted bool, _ error) {\n\tdefer resp.Body.Close()\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging on a stalled connection. done lets the goroutine\n\t// exit when readStream returns normally — otherwise it outlives the call, and\n\t// blocks forever on a non-cancellable context whose Done() is nil.\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tgo func() {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tresp.Body.Close()\n\t\tcase <-done:\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: decode stream: %w\", c.name, err)\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\treturn emitted, fmt.Errorf(\"%s: read stream: %w\", c.name, err)\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n\treturn emitted, nil\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always present (never omitted): DeepSeek's strict deserializer\n\t// rejects a message missing the field. A pure tool_calls assistant turn\n\t// serializes as null (OpenAI-spec, and what strict clones expect); every\n\t// other role/message serializes as a string, empty included — null is\n\t// rejected by some backends for a tool message.\n\tContent *string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "9328c48fb0742bb70c8c1a1aab325808ddc55aa4": {"t": 1785750702.650777, "n": 16588, "h": "8cd7dc925322a845", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\teffort = strings.ToLower(strings.TrimSpace(effort))\n\tif effort == \"auto\" {\n\t\teffort = \"\"\n\t}\n\tprotocol, _ := cfg.Extra[\"reasoning_protocol\"].(string)\n\tprotocol = normalizeReasoningProtocol(protocol)\n\tdeepseek := protocol == \"deepseek\" || (protocol == \"\" && isDeepSeekBaseURL(cfg.BaseURL))\n\tswitch {\n\tcase protocol == \"none\":\n\t\teffort = \"\"\n\tcase deepseek:\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\tcase effort != \"\":\n\t\t// Non-DeepSeek backends use OpenAI's reasoning_effort scale (low/medium/\n\t\t// high); \"max\" is a DeepSeek-ism MiMo et al. reject with 400, so clamp it\n\t\t// to the OpenAI ceiling and reject other values at boot, not at request time.\n\t\tswitch effort {\n\t\tcase \"max\":\n\t\t\teffort = \"high\"\n\t\tcase \"low\", \"medium\", \"high\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q: effort must be low, medium, or high\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc isDeepSeekBaseURL(baseURL string) bool {\n\tu, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.ToLower(u.Hostname())\n\treturn host == \"api.deepseek.com\" || strings.HasSuffix(host, \".deepseek.com\")\n}\n\nfunc normalizeReasoningProtocol(raw string) string {\n\tswitch strings.ToLower(strings.TrimSpace(raw)) {\n\tcase \"deepseek\", \"openai\", \"none\":\n\t\treturn strings.ToLower(strings.TrimSpace(raw))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn\n// allocates a buffer, marshals the request, and sends it — pooling avoids the\n// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is\n// provider-level (not global) so OpenAI and Anthropic don't compete.\nvar bufPool = sync.Pool{\n\tNew: func() any { return new(bytes.Buffer) },\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbuf := bufPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tif err := json.NewEncoder(buf).Encode(c.buildRequest(req)); err != nil {\n\t\tbufPool.Put(buf)\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\tbody := make([]byte, buf.Len())\n\tcopy(body, buf.Bytes())\n\tbufPool.Put(buf)\n\n\tnewReq := func(ctx context.Context) (*http.Request, error) {\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn httpReq, nil\n\t}\n\tresp, err := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.streamWithReconnect(ctx, resp, newReq, out)\n\treturn out, nil\n}\n\n// maxStreamReconnects bounds how many times a mid-stream connection drop is\n// replayed from scratch before the error is surfaced — each replay re-runs the\n// whole request (cheap under prompt caching, but not free).\nconst maxStreamReconnects = 3\n\n// streamWithReconnect drives readStream and, when the connection is cut before\n// any model output has been forwarded, replays the request rather than failing\n// the turn. Once a token (reasoning/text/tool-call) has been emitted, a replay\n// would duplicate output, so the error is surfaced instead.\nfunc (c *client) streamWithReconnect(ctx context.Context, resp *http.Response, newReq func(context.Context) (*http.Request, error), out chan<- provider.Chunk) {\n\tdefer close(out)\n\tfor attempt := 0; ; attempt++ {\n\t\temitted, err := c.readStream(ctx, resp, out)\n\t\tif err == nil {\n\t\t\treturn\n\t\t}\n\t\tif !provider.IsConnReset(err) {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tif emitted {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: &provider.StreamInterruptedError{Err: err}}\n\t\t\treturn\n\t\t}\n\t\tif attempt >= maxStreamReconnects {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tnext, rerr := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\t\tif rerr != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: rerr}\n\t\t\treturn\n\t\t}\n\t\tresp = next\n\t}\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek counts re-sent reasoning as billable prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain); MiMo accepts\n\t\t// it but does not require it (verified empirically: multi-turn tool-call\n\t\t// sessions work fine without it, saving ~18 tokens/turn). The session\n\t\t// still keeps it (for display/archive); we just don't pay to re-upload it.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tif m.Role != provider.RoleAssistant || len(cm.ToolCalls) == 0 || m.Content != \"\" {\n\t\t\tcontent := m.Content\n\t\t\tcm.Content = &content\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tif c.deepseek {\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\t}\n\treturn out\n}\n\n// readStream parses one SSE response into chunks: text deltas stream live,\n// tool-call fragments accumulate by index and emit complete on [DONE], and a\n// ChunkToolCallStart fires the moment a call's name is known. It returns whether\n// any model output was forwarded (so the caller can decide a replay is safe) and\n// the first fatal error — a nil error means the stream reached [DONE].\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) (emitted bool, _ error) {\n\tdefer resp.Body.Close()\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging on a stalled connection. done lets the goroutine\n\t// exit when readStream returns normally — otherwise it outlives the call, and\n\t// blocks forever on a non-cancellable context whose Done() is nil.\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tgo func() {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tresp.Body.Close()\n\t\tcase <-done:\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: decode stream: %w\", c.name, err)\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\treturn emitted, fmt.Errorf(\"%s: read stream: %w\", c.name, err)\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n\treturn emitted, nil\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always present (never omitted): DeepSeek's strict deserializer\n\t// rejects a message missing the field. A pure tool_calls assistant turn\n\t// serializes as null (OpenAI-spec, and what strict clones expect); every\n\t// other role/message serializes as a string, empty included — null is\n\t// rejected by some backends for a tool message.\n\tContent *string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "8feb6b3f17567aebe2192d73e83d684ba0707b71": {"t": 1785750703.7724566, "n": 16603, "h": "03f6b30afe4f8028", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\tprotocol, _ := cfg.Extra[\"reasoning_protocol\"].(string)\n\tprotocol = normalizeReasoningProtocol(protocol)\n\tdeepseek := protocol == \"deepseek\" || (protocol == \"\" && isDeepSeekBaseURL(cfg.BaseURL))\n\tswitch {\n\tcase protocol == \"none\":\n\t\teffort = \"\"\n\tcase deepseek:\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\tcase effort != \"\":\n\t\t// Non-DeepSeek backends use OpenAI's reasoning_effort scale (low/medium/\n\t\t// high); \"max\" is a DeepSeek-ism MiMo et al. reject with 400, so clamp it\n\t\t// to the OpenAI ceiling and reject other values at boot, not at request time.\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"max\":\n\t\t\teffort = \"high\"\n\t\tcase \"low\", \"medium\", \"high\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q: effort must be low, medium, or high\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc isDeepSeekBaseURL(baseURL string) bool {\n\tu, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.ToLower(u.Hostname())\n\treturn host == \"api.deepseek.com\" || strings.HasSuffix(host, \".deepseek.com\")\n}\n\nfunc normalizeReasoningProtocol(raw string) string {\n\tswitch strings.ToLower(strings.TrimSpace(raw)) {\n\tcase \"deepseek\", \"openai\", \"none\":\n\t\treturn strings.ToLower(strings.TrimSpace(raw))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn\n// allocates a buffer, marshals the request, and sends it — pooling avoids the\n// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is\n// provider-level (not global) so OpenAI and Anthropic don't compete.\nvar bufPool = sync.Pool{\n\tNew: func() any { return new(bytes.Buffer) },\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbuf := bufPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tif err := json.NewEncoder(buf).Encode(c.buildRequest(req)); err != nil {\n\t\tbufPool.Put(buf)\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\tbody := make([]byte, buf.Len())\n\tcopy(body, buf.Bytes())\n\tbufPool.Put(buf)\n\n\tnewReq := func(ctx context.Context) (*http.Request, error) {\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn httpReq, nil\n\t}\n\tresp, err := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.streamWithReconnect(ctx, resp, newReq, out)\n\treturn out, nil\n}\n\n// maxStreamReconnects bounds how many times a mid-stream connection drop is\n// replayed from scratch before the error is surfaced — each replay re-runs the\n// whole request (cheap under prompt caching, but not free).\nconst maxStreamReconnects = 3\n\n// streamWithReconnect drives readStream and, when the connection is cut before\n// any model output has been forwarded, replays the request rather than failing\n// the turn. Once a token (reasoning/text/tool-call) has been emitted, a replay\n// would duplicate output, so the error is surfaced instead.\nfunc (c *client) streamWithReconnect(ctx context.Context, resp *http.Response, newReq func(context.Context) (*http.Request, error), out chan<- provider.Chunk) {\n\tdefer close(out)\n\tfor attempt := 0; ; attempt++ {\n\t\temitted, err := c.readStream(ctx, resp, out)\n\t\tif err == nil {\n\t\t\treturn\n\t\t}\n\t\tif !provider.IsConnReset(err) {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tif emitted {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: &provider.StreamInterruptedError{Err: err}}\n\t\t\treturn\n\t\t}\n\t\tif attempt >= maxStreamReconnects {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tnext, rerr := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\t\tif rerr != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: rerr}\n\t\t\treturn\n\t\t}\n\t\tresp = next\n\t}\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek counts re-sent reasoning as billable prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain); MiMo accepts\n\t\t// it but does not require it (verified empirically: multi-turn tool-call\n\t\t// sessions work fine without it, saving ~18 tokens/turn). The session\n\t\t// still keeps it (for display/archive); we just don't pay to re-upload it.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tif m.Role != provider.RoleAssistant || len(cm.ToolCalls) == 0 || m.Content != \"\" {\n\t\t\tcontent := m.Content\n\t\t\tcm.Content = &content\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tif c.deepseek {\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\t}\n\treturn out\n}\n\n// readStream parses one SSE response into chunks: text deltas stream live,\n// tool-call fragments accumulate by index and emit complete on [DONE], and a\n// ChunkToolCallStart fires the moment a call's name is known. It returns whether\n// any model output was forwarded (so the caller can decide a replay is safe) and\n// the first fatal error — a nil error means the stream reached [DONE].\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) (emitted bool, _ error) {\n\tdefer resp.Body.Close()\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging on a stalled connection. done lets the goroutine\n\t// exit when readStream returns normally — otherwise it outlives the call, and\n\t// blocks forever on a non-cancellable context whose Done() is nil.\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tgo func() {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tresp.Body.Close()\n\t\tcase <-done:\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: decode stream: %w\", c.name, err)\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\treturn emitted, fmt.Errorf(\"%s: read stream: %w\", c.name, err)\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n\treturn emitted, nil\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always present (never omitted): DeepSeek's strict deserializer\n\t// rejects a message missing the field. A pure tool_calls assistant turn\n\t// serializes as null (OpenAI-spec, and what strict clones expect); every\n\t// other role/message serializes as a string, empty included — null is\n\t// rejected by some backends for a tool message.\n\tContent *string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "304e1152cddce0b025861b5accc77c52b20aee04": {"t": 1785750704.896895, "n": 16546, "h": "1b0fc917fb5b6b54", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\tprotocol, _ := cfg.Extra[\"reasoning_protocol\"].(string)\n\tprotocol = normalizeReasoningProtocol(protocol)\n\tdeepseek := protocol == \"deepseek\" || (protocol == \"\" && isDeepSeekBaseURL(cfg.BaseURL))\n\tswitch {\n\tcase protocol == \"none\":\n\t\teffort = \"\"\n\tcase deepseek:\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\tcase effort != \"\":\n\t\t// Non-DeepSeek backends use OpenAI's reasoning_effort scale (low/medium/\n\t\t// high); \"max\" is a DeepSeek-ism MiMo et al. reject with 400, so clamp it\n\t\t// to the OpenAI ceiling and reject other values at boot, not at request time.\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"max\":\n\t\t\teffort = \"high\"\n\t\tcase \"low\", \"medium\", \"high\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q: effort must be low, medium, or high\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc isDeepSeekBaseURL(baseURL string) bool {\n\tu, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.ToLower(u.Hostname())\n\treturn host == \"api.deepseek.com\" || strings.HasSuffix(host, \".deepseek.com\")\n}\n\nfunc normalizeReasoningProtocol(raw string) string {\n\tswitch strings.ToLower(strings.TrimSpace(raw)) {\n\tcase \"deepseek\", \"openai\", \"none\":\n\t\treturn strings.ToLower(strings.TrimSpace(raw))\n\tdefault:\n\t\treturn \"\"\n\t}\n}\n\n// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn\n// allocates a buffer, marshals the request, and sends it — pooling avoids the\n// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is\n// provider-level (not global) so OpenAI and Anthropic don't compete.\nvar bufPool = sync.Pool{\n\tNew: func() any { return new(bytes.Buffer) },\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbuf := bufPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tif err := json.NewEncoder(buf).Encode(c.buildRequest(req)); err != nil {\n\t\tbufPool.Put(buf)\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\tbody := make([]byte, buf.Len())\n\tcopy(body, buf.Bytes())\n\tbufPool.Put(buf)\n\n\tnewReq := func(ctx context.Context) (*http.Request, error) {\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn httpReq, nil\n\t}\n\tresp, err := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.streamWithReconnect(ctx, resp, newReq, out)\n\treturn out, nil\n}\n\n// maxStreamReconnects bounds how many times a mid-stream connection drop is\n// replayed from scratch before the error is surfaced — each replay re-runs the\n// whole request (cheap under prompt caching, but not free).\nconst maxStreamReconnects = 3\n\n// streamWithReconnect drives readStream and, when the connection is cut before\n// any model output has been forwarded, replays the request rather than failing\n// the turn. Once a token (reasoning/text/tool-call) has been emitted, a replay\n// would duplicate output, so the error is surfaced instead.\nfunc (c *client) streamWithReconnect(ctx context.Context, resp *http.Response, newReq func(context.Context) (*http.Request, error), out chan<- provider.Chunk) {\n\tdefer close(out)\n\tfor attempt := 0; ; attempt++ {\n\t\temitted, err := c.readStream(ctx, resp, out)\n\t\tif err == nil {\n\t\t\treturn\n\t\t}\n\t\tif !provider.IsConnReset(err) {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tif emitted {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: &provider.StreamInterruptedError{Err: err}}\n\t\t\treturn\n\t\t}\n\t\tif attempt >= maxStreamReconnects {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tnext, rerr := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\t\tif rerr != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: rerr}\n\t\t\treturn\n\t\t}\n\t\tresp = next\n\t}\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek counts re-sent reasoning as billable prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain); MiMo accepts\n\t\t// it but does not require it (verified empirically: multi-turn tool-call\n\t\t// sessions work fine without it, saving ~18 tokens/turn). The session\n\t\t// still keeps it (for display/archive); we just don't pay to re-upload it.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tif c.deepseek {\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\t}\n\treturn out\n}\n\n// readStream parses one SSE response into chunks: text deltas stream live,\n// tool-call fragments accumulate by index and emit complete on [DONE], and a\n// ChunkToolCallStart fires the moment a call's name is known. It returns whether\n// any model output was forwarded (so the caller can decide a replay is safe) and\n// the first fatal error — a nil error means the stream reached [DONE].\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) (emitted bool, _ error) {\n\tdefer resp.Body.Close()\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging on a stalled connection. done lets the goroutine\n\t// exit when readStream returns normally — otherwise it outlives the call, and\n\t// blocks forever on a non-cancellable context whose Done() is nil.\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tgo func() {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tresp.Body.Close()\n\t\tcase <-done:\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: decode stream: %w\", c.name, err)\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\treturn emitted, fmt.Errorf(\"%s: read stream: %w\", c.name, err)\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n\treturn emitted, nil\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "faed6fc7d0bede3d9d059817698da8e2597d6c8e": {"t": 1785750706.0420651, "n": 16133, "h": "3b58422ad9589fb4", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\tdeepseek := isDeepSeekBaseURL(cfg.BaseURL)\n\tif deepseek {\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\t} else if effort != \"\" {\n\t\t// Non-DeepSeek backends use OpenAI's reasoning_effort scale (low/medium/\n\t\t// high); \"max\" is a DeepSeek-ism MiMo et al. reject with 400, so clamp it\n\t\t// to the OpenAI ceiling and reject other values at boot, not at request time.\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"max\":\n\t\t\teffort = \"high\"\n\t\tcase \"low\", \"medium\", \"high\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q: effort must be low, medium, or high\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc isDeepSeekBaseURL(baseURL string) bool {\n\tu, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.ToLower(u.Hostname())\n\treturn host == \"api.deepseek.com\" || strings.HasSuffix(host, \".deepseek.com\")\n}\n\n// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn\n// allocates a buffer, marshals the request, and sends it — pooling avoids the\n// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is\n// provider-level (not global) so OpenAI and Anthropic don't compete.\nvar bufPool = sync.Pool{\n\tNew: func() any { return new(bytes.Buffer) },\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbuf := bufPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tif err := json.NewEncoder(buf).Encode(c.buildRequest(req)); err != nil {\n\t\tbufPool.Put(buf)\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\tbody := make([]byte, buf.Len())\n\tcopy(body, buf.Bytes())\n\tbufPool.Put(buf)\n\n\tnewReq := func(ctx context.Context) (*http.Request, error) {\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn httpReq, nil\n\t}\n\tresp, err := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.streamWithReconnect(ctx, resp, newReq, out)\n\treturn out, nil\n}\n\n// maxStreamReconnects bounds how many times a mid-stream connection drop is\n// replayed from scratch before the error is surfaced — each replay re-runs the\n// whole request (cheap under prompt caching, but not free).\nconst maxStreamReconnects = 3\n\n// streamWithReconnect drives readStream and, when the connection is cut before\n// any model output has been forwarded, replays the request rather than failing\n// the turn. Once a token (reasoning/text/tool-call) has been emitted, a replay\n// would duplicate output, so the error is surfaced instead.\nfunc (c *client) streamWithReconnect(ctx context.Context, resp *http.Response, newReq func(context.Context) (*http.Request, error), out chan<- provider.Chunk) {\n\tdefer close(out)\n\tfor attempt := 0; ; attempt++ {\n\t\temitted, err := c.readStream(ctx, resp, out)\n\t\tif err == nil {\n\t\t\treturn\n\t\t}\n\t\tif !provider.IsConnReset(err) {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tif emitted {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: &provider.StreamInterruptedError{Err: err}}\n\t\t\treturn\n\t\t}\n\t\tif attempt >= maxStreamReconnects {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tnext, rerr := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\t\tif rerr != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: rerr}\n\t\t\treturn\n\t\t}\n\t\tresp = next\n\t}\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek counts re-sent reasoning as billable prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain); MiMo accepts\n\t\t// it but does not require it (verified empirically: multi-turn tool-call\n\t\t// sessions work fine without it, saving ~18 tokens/turn). The session\n\t\t// still keeps it (for display/archive); we just don't pay to re-upload it.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tif c.deepseek {\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\t}\n\treturn out\n}\n\n// readStream parses one SSE response into chunks: text deltas stream live,\n// tool-call fragments accumulate by index and emit complete on [DONE], and a\n// ChunkToolCallStart fires the moment a call's name is known. It returns whether\n// any model output was forwarded (so the caller can decide a replay is safe) and\n// the first fatal error — a nil error means the stream reached [DONE].\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) (emitted bool, _ error) {\n\tdefer resp.Body.Close()\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging on a stalled connection. done lets the goroutine\n\t// exit when readStream returns normally — otherwise it outlives the call, and\n\t// blocks forever on a non-cancellable context whose Done() is nil.\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tgo func() {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tresp.Body.Close()\n\t\tcase <-done:\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: decode stream: %w\", c.name, err)\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\treturn emitted, fmt.Errorf(\"%s: read stream: %w\", c.name, err)\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n\treturn emitted, nil\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "d5df0e5ba2210d929468566e80243ea1a0cf74b9": {"t": 1785750707.1649318, "n": 15934, "h": "670556b09d79ac5f", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\tdeepseek := isDeepSeekBaseURL(cfg.BaseURL)\n\tif deepseek {\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\t} else if effort != \"\" {\n\t\t// Non-DeepSeek backends use OpenAI's reasoning_effort scale (low/medium/\n\t\t// high); \"max\" is a DeepSeek-ism MiMo et al. reject with 400, so clamp it\n\t\t// to the OpenAI ceiling and reject other values at boot, not at request time.\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"max\":\n\t\t\teffort = \"high\"\n\t\tcase \"low\", \"medium\", \"high\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q: effort must be low, medium, or high\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc isDeepSeekBaseURL(baseURL string) bool {\n\tu, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.ToLower(u.Hostname())\n\treturn host == \"api.deepseek.com\" || strings.HasSuffix(host, \".deepseek.com\")\n}\n\n// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn\n// allocates a buffer, marshals the request, and sends it — pooling avoids the\n// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is\n// provider-level (not global) so OpenAI and Anthropic don't compete.\nvar bufPool = sync.Pool{\n\tNew: func() any { return new(bytes.Buffer) },\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbuf := bufPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tif err := json.NewEncoder(buf).Encode(c.buildRequest(req)); err != nil {\n\t\tbufPool.Put(buf)\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\tbody := make([]byte, buf.Len())\n\tcopy(body, buf.Bytes())\n\tbufPool.Put(buf)\n\n\tnewReq := func(ctx context.Context) (*http.Request, error) {\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn httpReq, nil\n\t}\n\tresp, err := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.streamWithReconnect(ctx, resp, newReq, out)\n\treturn out, nil\n}\n\n// maxStreamReconnects bounds how many times a mid-stream connection drop is\n// replayed from scratch before the error is surfaced — each replay re-runs the\n// whole request (cheap under prompt caching, but not free).\nconst maxStreamReconnects = 3\n\n// streamWithReconnect drives readStream and, when the connection is cut before\n// any model output has been forwarded, replays the request rather than failing\n// the turn. Once a token (reasoning/text/tool-call) has been emitted, a replay\n// would duplicate output, so the error is surfaced instead.\nfunc (c *client) streamWithReconnect(ctx context.Context, resp *http.Response, newReq func(context.Context) (*http.Request, error), out chan<- provider.Chunk) {\n\tdefer close(out)\n\tfor attempt := 0; ; attempt++ {\n\t\temitted, err := c.readStream(ctx, resp, out)\n\t\tif err == nil {\n\t\t\treturn\n\t\t}\n\t\tif emitted || attempt >= maxStreamReconnects || !provider.IsConnReset(err) {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: err}\n\t\t\treturn\n\t\t}\n\t\tnext, rerr := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\t\tif rerr != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: rerr}\n\t\t\treturn\n\t\t}\n\t\tresp = next\n\t}\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek counts re-sent reasoning as billable prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain); MiMo accepts\n\t\t// it but does not require it (verified empirically: multi-turn tool-call\n\t\t// sessions work fine without it, saving ~18 tokens/turn). The session\n\t\t// still keeps it (for display/archive); we just don't pay to re-upload it.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tif c.deepseek {\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\t}\n\treturn out\n}\n\n// readStream parses one SSE response into chunks: text deltas stream live,\n// tool-call fragments accumulate by index and emit complete on [DONE], and a\n// ChunkToolCallStart fires the moment a call's name is known. It returns whether\n// any model output was forwarded (so the caller can decide a replay is safe) and\n// the first fatal error — a nil error means the stream reached [DONE].\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) (emitted bool, _ error) {\n\tdefer resp.Body.Close()\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging on a stalled connection. done lets the goroutine\n\t// exit when readStream returns normally — otherwise it outlives the call, and\n\t// blocks forever on a non-cancellable context whose Done() is nil.\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tgo func() {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tresp.Body.Close()\n\t\tcase <-done:\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: decode stream: %w\", c.name, err)\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\treturn emitted, fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\temitted = true\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\temitted = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\treturn emitted, fmt.Errorf(\"%s: read stream: %w\", c.name, err)\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n\treturn emitted, nil\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "e6a05ed9005e07da0c84aaa0aa47c9d46c2e4e34": {"t": 1785750708.2795653, "n": 14677, "h": "c25749c6b074e757", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\tdeepseek := isDeepSeekBaseURL(cfg.BaseURL)\n\tif deepseek {\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\t} else if effort != \"\" {\n\t\t// Non-DeepSeek backends use OpenAI's reasoning_effort scale (low/medium/\n\t\t// high); \"max\" is a DeepSeek-ism MiMo et al. reject with 400, so clamp it\n\t\t// to the OpenAI ceiling and reject other values at boot, not at request time.\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"max\":\n\t\t\teffort = \"high\"\n\t\tcase \"low\", \"medium\", \"high\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q: effort must be low, medium, or high\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc isDeepSeekBaseURL(baseURL string) bool {\n\tu, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.ToLower(u.Hostname())\n\treturn host == \"api.deepseek.com\" || strings.HasSuffix(host, \".deepseek.com\")\n}\n\n// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn\n// allocates a buffer, marshals the request, and sends it — pooling avoids the\n// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is\n// provider-level (not global) so OpenAI and Anthropic don't compete.\nvar bufPool = sync.Pool{\n\tNew: func() any { return new(bytes.Buffer) },\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbuf := bufPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tif err := json.NewEncoder(buf).Encode(c.buildRequest(req)); err != nil {\n\t\tbufPool.Put(buf)\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\tbody := make([]byte, buf.Len())\n\tcopy(body, buf.Bytes())\n\tbufPool.Put(buf)\n\n\tnewReq := func(ctx context.Context) (*http.Request, error) {\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn httpReq, nil\n\t}\n\tresp, err := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(ctx, resp, out)\n\treturn out, nil\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek counts re-sent reasoning as billable prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain); MiMo accepts\n\t\t// it but does not require it (verified empirically: multi-turn tool-call\n\t\t// sessions work fine without it, saving ~18 tokens/turn). The session\n\t\t// still keeps it (for display/archive); we just don't pay to re-upload it.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tif c.deepseek {\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\t}\n\treturn out\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging on a stalled connection. done lets the goroutine\n\t// exit when readStream returns normally — otherwise it outlives the call, and\n\t// blocks forever on a non-cancellable context whose Done() is nil.\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tgo func() {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tresp.Body.Close()\n\t\tcase <-done:\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "b7ba277879de00d3b625660904e8c14e3ed43e2a": {"t": 1785750709.5733585, "n": 14680, "h": "5fc51854931dd09c", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\tdeepseek := isDeepSeekBaseURL(cfg.BaseURL)\n\tif deepseek {\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\t} else if effort != \"\" {\n\t\t// Non-DeepSeek backends use OpenAI's reasoning_effort scale (low/medium/\n\t\t// high); \"max\" is a DeepSeek-ism MiMo et al. reject with 400, so clamp it\n\t\t// to the OpenAI ceiling and reject other values at boot, not at request time.\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"max\":\n\t\t\teffort = \"high\"\n\t\tcase \"low\", \"medium\", \"high\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q: effort must be low, medium, or high\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, 0, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc isDeepSeekBaseURL(baseURL string) bool {\n\tu, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.ToLower(u.Hostname())\n\treturn host == \"api.deepseek.com\" || strings.HasSuffix(host, \".deepseek.com\")\n}\n\n// bufPool reuses byte buffers for JSON-marshalled request bodies. Each turn\n// allocates a buffer, marshals the request, and sends it — pooling avoids the\n// GC churn from repeated alloc/free of ~10-100KB buffers. The pool is\n// provider-level (not global) so OpenAI and Anthropic don't compete.\nvar bufPool = sync.Pool{\n\tNew: func() any { return new(bytes.Buffer) },\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbuf := bufPool.Get().(*bytes.Buffer)\n\tbuf.Reset()\n\tif err := json.NewEncoder(buf).Encode(c.buildRequest(req)); err != nil {\n\t\tbufPool.Put(buf)\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\tbody := make([]byte, buf.Len())\n\tcopy(body, buf.Bytes())\n\tbufPool.Put(buf)\n\n\tnewReq := func(ctx context.Context) (*http.Request, error) {\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn httpReq, nil\n\t}\n\tresp, err := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(ctx, resp, out)\n\treturn out, nil\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek counts re-sent reasoning as billable prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain); MiMo accepts\n\t\t// it but does not require it (verified empirically: multi-turn tool-call\n\t\t// sessions work fine without it, saving ~18 tokens/turn). The session\n\t\t// still keeps it (for display/archive); we just don't pay to re-upload it.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tif c.deepseek {\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\t}\n\treturn out\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging on a stalled connection. done lets the goroutine\n\t// exit when readStream returns normally — otherwise it outlives the call, and\n\t// blocks forever on a non-cancellable context whose Done() is nil.\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tgo func() {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tresp.Body.Close()\n\t\tcase <-done:\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "f9f68e23ace3eeebba26121f5866c45b10e5a88e": {"t": 1785750710.7187843, "n": 14145, "h": "0fe0f5d22d7cc83f", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\tdeepseek := isDeepSeekBaseURL(cfg.BaseURL)\n\tif deepseek {\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\t} else if effort != \"\" {\n\t\t// Non-DeepSeek backends use OpenAI's reasoning_effort scale (low/medium/\n\t\t// high); \"max\" is a DeepSeek-ism MiMo et al. reject with 400, so clamp it\n\t\t// to the OpenAI ceiling and reject other values at boot, not at request time.\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"max\":\n\t\t\teffort = \"high\"\n\t\tcase \"low\", \"medium\", \"high\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q: effort must be low, medium, or high\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, 0, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc isDeepSeekBaseURL(baseURL string) bool {\n\tu, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.ToLower(u.Hostname())\n\treturn host == \"api.deepseek.com\" || strings.HasSuffix(host, \".deepseek.com\")\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\n\tnewReq := func(ctx context.Context) (*http.Request, error) {\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn httpReq, nil\n\t}\n\tresp, err := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(ctx, resp, out)\n\treturn out, nil\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek counts re-sent reasoning as billable prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain); MiMo accepts\n\t\t// it but does not require it (verified empirically: multi-turn tool-call\n\t\t// sessions work fine without it, saving ~18 tokens/turn). The session\n\t\t// still keeps it (for display/archive); we just don't pay to re-upload it.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tif c.deepseek {\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\t}\n\treturn out\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging on a stalled connection. done lets the goroutine\n\t// exit when readStream returns normally — otherwise it outlives the call, and\n\t// blocks forever on a non-cancellable context whose Done() is nil.\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tgo func() {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tresp.Body.Close()\n\t\tcase <-done:\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "b5436493fc93ffd1502a854572eb1188e1e6b216": {"t": 1785750711.8424747, "n": 13641, "h": "bf1b0695c9157bc9", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\tdeepseek := isDeepSeekBaseURL(cfg.BaseURL)\n\tif deepseek {\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, 0, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc isDeepSeekBaseURL(baseURL string) bool {\n\tu, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.ToLower(u.Hostname())\n\treturn host == \"api.deepseek.com\" || strings.HasSuffix(host, \".deepseek.com\")\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\n\tnewReq := func(ctx context.Context) (*http.Request, error) {\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\t\treturn httpReq, nil\n\t}\n\tresp, err := provider.SendWithRetry(ctx, c.http, c.name, c.keyEnv, newReq)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(ctx, resp, out)\n\treturn out, nil\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek counts re-sent reasoning as billable prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain); MiMo accepts\n\t\t// it but does not require it (verified empirically: multi-turn tool-call\n\t\t// sessions work fine without it, saving ~18 tokens/turn). The session\n\t\t// still keeps it (for display/archive); we just don't pay to re-upload it.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tif c.deepseek {\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\t}\n\treturn out\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging on a stalled connection. done lets the goroutine\n\t// exit when readStream returns normally — otherwise it outlives the call, and\n\t// blocks forever on a non-cancellable context whose Done() is nil.\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tgo func() {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tresp.Body.Close()\n\t\tcase <-done:\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "2c7f7e17a62582cdd35cca9d3cd8a4f89cda0819": {"t": 1785750713.0125508, "n": 16442, "h": "2ad1bedfcfd29e43", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\tdeepseek := isDeepSeekBaseURL(cfg.BaseURL)\n\tif deepseek {\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\", \"off\": // \"off\" is a retired level (disabled thinking); fall back to the default depth\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high or max\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, 0, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc isDeepSeekBaseURL(baseURL string) bool {\n\tu, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.ToLower(u.Hostname())\n\treturn host == \"api.deepseek.com\" || strings.HasSuffix(host, \".deepseek.com\")\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\n\tresp, err := c.sendWithRetry(ctx, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(ctx, resp, out)\n\treturn out, nil\n}\n\n// sendWithRetry POSTs the request body and returns the streaming response,\n// retrying on transient network errors and retryable HTTP statuses (408, 429,\n// 5xx) with exponential backoff + jitter. Retries only cover the connection +\n// header phase; once we hand the response to readStream, mid-stream failures\n// surface as ChunkError without retry, since the model has already started\n// emitting tokens we'd otherwise duplicate.\nfunc (c *client) sendWithRetry(ctx context.Context, body []byte) (*http.Response, error) {\n\tconst maxAttempts = 3\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tif attempt > 0 {\n\t\t\tdelay := time.Duration(1<<(attempt-1))*500*time.Millisecond + time.Duration(rand.Intn(250))*time.Millisecond\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(delay):\n\t\t\t}\n\t\t}\n\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%s: build request: %w\", c.name, err)\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\n\t\tresp, err := c.http.Do(httpReq)\n\t\tif err != nil {\n\t\t\tif !isTransientErr(err) {\n\t\t\t\treturn nil, fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\t}\n\t\t\tlastErr = fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\tcontinue\n\t\t}\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tmsg, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))\n\t\tif readErr != nil {\n\t\t\tmsg = []byte(fmt.Sprintf(\"(could not read error body: %v)\", readErr))\n\t\t}\n\t\t// Drain any remaining body so the HTTP connection can be reused by the\n\t\t// transport pool, then close.\n\t\t_, _ = io.Copy(io.Discard, resp.Body)\n\t\tresp.Body.Close()\n\t\t// A rejected key is a configuration problem, not a transient one — give\n\t\t// an actionable error instead of dumping the raw status body.\n\t\tif resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {\n\t\t\treturn nil, &provider.AuthError{Provider: c.name, KeyEnv: c.keyEnv, Status: resp.StatusCode}\n\t\t}\n\t\tstatusErr := fmt.Errorf(\"%s: status %d: %s\", c.name, resp.StatusCode, strings.TrimSpace(string(msg)))\n\t\tif !isRetryableStatus(resp.StatusCode) {\n\t\t\treturn nil, statusErr\n\t\t}\n\t\tlastErr = statusErr\n\t}\n\treturn nil, lastErr\n}\n\n// isRetryableStatus returns true for HTTP status codes a transient backoff can\n// reasonably recover from: 408 (request timeout), 429 (rate limit), and 5xx.\n// 4xx other than 408/429 (auth, validation, not-found) are caller bugs and\n// won't fix themselves on retry.\nfunc isRetryableStatus(s int) bool {\n\treturn s == http.StatusRequestTimeout || s == http.StatusTooManyRequests || (s >= 500 && s <= 599)\n}\n\n// isTransientErr classifies HTTP client errors. ctx cancellation and deadline\n// expiry are caller intent — never retry those. Everything else (DNS failures,\n// connection resets, abrupt EOF, etc.) gets one more shot.\nfunc isTransientErr(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek counts re-sent reasoning as billable prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain); MiMo accepts\n\t\t// it but does not require it (verified empirically: multi-turn tool-call\n\t\t// sessions work fine without it, saving ~18 tokens/turn). The session\n\t\t// still keeps it (for display/archive); we just don't pay to re-upload it.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tif c.deepseek {\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\t}\n\treturn out\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging on a stalled connection. done lets the goroutine\n\t// exit when readStream returns normally — otherwise it outlives the call, and\n\t// blocks forever on a non-cancellable context whose Done() is nil.\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tgo func() {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tresp.Body.Close()\n\t\tcase <-done:\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "23f4220164d790ce4c373093135f767cf197d221": {"t": 1785750714.1436799, "n": 16459, "h": "34b8cbadc86e3452", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\tdeepseek := isDeepSeekBaseURL(cfg.BaseURL)\n\tif deepseek {\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\":\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\", \"off\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high, max, or off\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, 0, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc isDeepSeekBaseURL(baseURL string) bool {\n\tu, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.ToLower(u.Hostname())\n\treturn host == \"api.deepseek.com\" || strings.HasSuffix(host, \".deepseek.com\")\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\n\tresp, err := c.sendWithRetry(ctx, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(ctx, resp, out)\n\treturn out, nil\n}\n\n// sendWithRetry POSTs the request body and returns the streaming response,\n// retrying on transient network errors and retryable HTTP statuses (408, 429,\n// 5xx) with exponential backoff + jitter. Retries only cover the connection +\n// header phase; once we hand the response to readStream, mid-stream failures\n// surface as ChunkError without retry, since the model has already started\n// emitting tokens we'd otherwise duplicate.\nfunc (c *client) sendWithRetry(ctx context.Context, body []byte) (*http.Response, error) {\n\tconst maxAttempts = 3\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tif attempt > 0 {\n\t\t\tdelay := time.Duration(1<<(attempt-1))*500*time.Millisecond + time.Duration(rand.Intn(250))*time.Millisecond\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(delay):\n\t\t\t}\n\t\t}\n\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%s: build request: %w\", c.name, err)\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\n\t\tresp, err := c.http.Do(httpReq)\n\t\tif err != nil {\n\t\t\tif !isTransientErr(err) {\n\t\t\t\treturn nil, fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\t}\n\t\t\tlastErr = fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\tcontinue\n\t\t}\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tmsg, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))\n\t\tif readErr != nil {\n\t\t\tmsg = []byte(fmt.Sprintf(\"(could not read error body: %v)\", readErr))\n\t\t}\n\t\t// Drain any remaining body so the HTTP connection can be reused by the\n\t\t// transport pool, then close.\n\t\t_, _ = io.Copy(io.Discard, resp.Body)\n\t\tresp.Body.Close()\n\t\t// A rejected key is a configuration problem, not a transient one — give\n\t\t// an actionable error instead of dumping the raw status body.\n\t\tif resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {\n\t\t\treturn nil, &provider.AuthError{Provider: c.name, KeyEnv: c.keyEnv, Status: resp.StatusCode}\n\t\t}\n\t\tstatusErr := fmt.Errorf(\"%s: status %d: %s\", c.name, resp.StatusCode, strings.TrimSpace(string(msg)))\n\t\tif !isRetryableStatus(resp.StatusCode) {\n\t\t\treturn nil, statusErr\n\t\t}\n\t\tlastErr = statusErr\n\t}\n\treturn nil, lastErr\n}\n\n// isRetryableStatus returns true for HTTP status codes a transient backoff can\n// reasonably recover from: 408 (request timeout), 429 (rate limit), and 5xx.\n// 4xx other than 408/429 (auth, validation, not-found) are caller bugs and\n// won't fix themselves on retry.\nfunc isRetryableStatus(s int) bool {\n\treturn s == http.StatusRequestTimeout || s == http.StatusTooManyRequests || (s >= 500 && s <= 599)\n}\n\n// isTransientErr classifies HTTP client errors. ctx cancellation and deadline\n// expiry are caller intent — never retry those. Everything else (DNS failures,\n// connection resets, abrupt EOF, etc.) gets one more shot.\nfunc isTransientErr(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek counts re-sent reasoning as billable prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain); MiMo accepts\n\t\t// it but does not require it (verified empirically: multi-turn tool-call\n\t\t// sessions work fine without it, saving ~18 tokens/turn). The session\n\t\t// still keeps it (for display/archive); we just don't pay to re-upload it.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tif c.deepseek {\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\t\tif c.effort == \"off\" {\n\t\t\tout.Thinking.Type = \"disabled\"\n\t\t\tout.ReasoningEffort = \"\"\n\t\t}\n\t}\n\treturn out\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging on a stalled connection. done lets the goroutine\n\t// exit when readStream returns normally — otherwise it outlives the call, and\n\t// blocks forever on a non-cancellable context whose Done() is nil.\n\tdone := make(chan struct{})\n\tdefer close(done)\n\tgo func() {\n\t\tselect {\n\t\tcase <-ctx.Done():\n\t\t\tresp.Body.Close()\n\t\tcase <-done:\n\t\t}\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "757c4aab5a822886902371325dc61331f3b037f9": {"t": 1785750715.2919092, "n": 16214, "h": "56c6754906479261", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/netclient\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\tdeepseek := isDeepSeekBaseURL(cfg.BaseURL)\n\tif deepseek {\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\":\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\", \"off\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high, max, or off\", name)\n\t\t}\n\t}\n\thttpClient, err := newHTTPClient(cfg)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"openai: network: %w\", err)\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\teffort: effort,\n\t\thttp: httpClient,\n\t}, nil\n}\n\nfunc newHTTPClient(cfg provider.Config) (*http.Client, error) {\n\tspec, _ := cfg.Extra[\"proxy_spec\"].(netclient.ProxySpec)\n\treturn netclient.NewHTTPClient(spec, 0, netclient.TransportOptions{\n\t\tDialTimeout: 30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t})\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc isDeepSeekBaseURL(baseURL string) bool {\n\tu, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.ToLower(u.Hostname())\n\treturn host == \"api.deepseek.com\" || strings.HasSuffix(host, \".deepseek.com\")\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\n\tresp, err := c.sendWithRetry(ctx, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(ctx, resp, out)\n\treturn out, nil\n}\n\n// sendWithRetry POSTs the request body and returns the streaming response,\n// retrying on transient network errors and retryable HTTP statuses (408, 429,\n// 5xx) with exponential backoff + jitter. Retries only cover the connection +\n// header phase; once we hand the response to readStream, mid-stream failures\n// surface as ChunkError without retry, since the model has already started\n// emitting tokens we'd otherwise duplicate.\nfunc (c *client) sendWithRetry(ctx context.Context, body []byte) (*http.Response, error) {\n\tconst maxAttempts = 3\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tif attempt > 0 {\n\t\t\tdelay := time.Duration(1<<(attempt-1))*500*time.Millisecond + time.Duration(rand.Intn(250))*time.Millisecond\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(delay):\n\t\t\t}\n\t\t}\n\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%s: build request: %w\", c.name, err)\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\n\t\tresp, err := c.http.Do(httpReq)\n\t\tif err != nil {\n\t\t\tif !isTransientErr(err) {\n\t\t\t\treturn nil, fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\t}\n\t\t\tlastErr = fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\tcontinue\n\t\t}\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tmsg, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))\n\t\tif readErr != nil {\n\t\t\tmsg = []byte(fmt.Sprintf(\"(could not read error body: %v)\", readErr))\n\t\t}\n\t\t// Drain any remaining body so the HTTP connection can be reused by the\n\t\t// transport pool, then close.\n\t\t_, _ = io.Copy(io.Discard, resp.Body)\n\t\tresp.Body.Close()\n\t\t// A rejected key is a configuration problem, not a transient one — give\n\t\t// an actionable error instead of dumping the raw status body.\n\t\tif resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {\n\t\t\treturn nil, &provider.AuthError{Provider: c.name, KeyEnv: c.keyEnv, Status: resp.StatusCode}\n\t\t}\n\t\tstatusErr := fmt.Errorf(\"%s: status %d: %s\", c.name, resp.StatusCode, strings.TrimSpace(string(msg)))\n\t\tif !isRetryableStatus(resp.StatusCode) {\n\t\t\treturn nil, statusErr\n\t\t}\n\t\tlastErr = statusErr\n\t}\n\treturn nil, lastErr\n}\n\n// isRetryableStatus returns true for HTTP status codes a transient backoff can\n// reasonably recover from: 408 (request timeout), 429 (rate limit), and 5xx.\n// 4xx other than 408/429 (auth, validation, not-found) are caller bugs and\n// won't fix themselves on retry.\nfunc isRetryableStatus(s int) bool {\n\treturn s == http.StatusRequestTimeout || s == http.StatusTooManyRequests || (s >= 500 && s <= 599)\n}\n\n// isTransientErr classifies HTTP client errors. ctx cancellation and deadline\n// expiry are caller intent — never retry those. Everything else (DNS failures,\n// connection resets, abrupt EOF, etc.) gets one more shot.\nfunc isTransientErr(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek counts re-sent reasoning as billable prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain); MiMo accepts\n\t\t// it but does not require it (verified empirically: multi-turn tool-call\n\t\t// sessions work fine without it, saving ~18 tokens/turn). The session\n\t\t// still keeps it (for display/archive); we just don't pay to re-upload it.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tif c.deepseek {\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\t\tif c.effort == \"off\" {\n\t\t\tout.Thinking.Type = \"disabled\"\n\t\t\tout.ReasoningEffort = \"\"\n\t\t}\n\t}\n\treturn out\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging indefinitely on a stalled connection.\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tresp.Body.Close()\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "3bf6a6111a5b3ec16dc3178f0c8fcc7930206c00": {"t": 1785750716.3776782, "n": 15957, "h": "228efd1d5a488537", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\tdeepseek := isDeepSeekBaseURL(cfg.BaseURL)\n\tif deepseek {\n\t\teffort = strings.ToLower(strings.TrimSpace(effort))\n\t\tswitch effort {\n\t\tcase \"\":\n\t\t\teffort = \"high\"\n\t\tcase \"high\", \"max\", \"off\":\n\t\tdefault:\n\t\t\treturn nil, fmt.Errorf(\"openai: provider %q uses DeepSeek thinking; effort must be high, max, or off\", name)\n\t\t}\n\t}\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\tdeepseek: deepseek,\n\t\teffort: effort,\n\t\thttp: &http.Client{\n\t\t\tTransport: &http.Transport{\n\t\t\t\tDialContext: (&net.Dialer{\n\t\t\t\t\tTimeout: 30 * time.Second,\n\t\t\t\t\tKeepAlive: 30 * time.Second,\n\t\t\t\t}).DialContext,\n\t\t\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\t\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t\t\t},\n\t\t},\n\t}, nil\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\tdeepseek bool\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc isDeepSeekBaseURL(baseURL string) bool {\n\tu, err := url.Parse(baseURL)\n\tif err != nil {\n\t\treturn false\n\t}\n\thost := strings.ToLower(u.Hostname())\n\treturn host == \"api.deepseek.com\" || strings.HasSuffix(host, \".deepseek.com\")\n}\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\n\tresp, err := c.sendWithRetry(ctx, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(ctx, resp, out)\n\treturn out, nil\n}\n\n// sendWithRetry POSTs the request body and returns the streaming response,\n// retrying on transient network errors and retryable HTTP statuses (408, 429,\n// 5xx) with exponential backoff + jitter. Retries only cover the connection +\n// header phase; once we hand the response to readStream, mid-stream failures\n// surface as ChunkError without retry, since the model has already started\n// emitting tokens we'd otherwise duplicate.\nfunc (c *client) sendWithRetry(ctx context.Context, body []byte) (*http.Response, error) {\n\tconst maxAttempts = 3\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tif attempt > 0 {\n\t\t\tdelay := time.Duration(1<<(attempt-1))*500*time.Millisecond + time.Duration(rand.Intn(250))*time.Millisecond\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(delay):\n\t\t\t}\n\t\t}\n\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%s: build request: %w\", c.name, err)\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\n\t\tresp, err := c.http.Do(httpReq)\n\t\tif err != nil {\n\t\t\tif !isTransientErr(err) {\n\t\t\t\treturn nil, fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\t}\n\t\t\tlastErr = fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\tcontinue\n\t\t}\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tmsg, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))\n\t\tif readErr != nil {\n\t\t\tmsg = []byte(fmt.Sprintf(\"(could not read error body: %v)\", readErr))\n\t\t}\n\t\t// Drain any remaining body so the HTTP connection can be reused by the\n\t\t// transport pool, then close.\n\t\t_, _ = io.Copy(io.Discard, resp.Body)\n\t\tresp.Body.Close()\n\t\t// A rejected key is a configuration problem, not a transient one — give\n\t\t// an actionable error instead of dumping the raw status body.\n\t\tif resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {\n\t\t\treturn nil, &provider.AuthError{Provider: c.name, KeyEnv: c.keyEnv, Status: resp.StatusCode}\n\t\t}\n\t\tstatusErr := fmt.Errorf(\"%s: status %d: %s\", c.name, resp.StatusCode, strings.TrimSpace(string(msg)))\n\t\tif !isRetryableStatus(resp.StatusCode) {\n\t\t\treturn nil, statusErr\n\t\t}\n\t\tlastErr = statusErr\n\t}\n\treturn nil, lastErr\n}\n\n// isRetryableStatus returns true for HTTP status codes a transient backoff can\n// reasonably recover from: 408 (request timeout), 429 (rate limit), and 5xx.\n// 4xx other than 408/429 (auth, validation, not-found) are caller bugs and\n// won't fix themselves on retry.\nfunc isRetryableStatus(s int) bool {\n\treturn s == http.StatusRequestTimeout || s == http.StatusTooManyRequests || (s >= 500 && s <= 599)\n}\n\n// isTransientErr classifies HTTP client errors. ctx cancellation and deadline\n// expiry are caller intent — never retry those. Everything else (DNS failures,\n// connection resets, abrupt EOF, etc.) gets one more shot.\nfunc isTransientErr(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek counts re-sent reasoning as billable prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain); MiMo accepts\n\t\t// it but does not require it (verified empirically: multi-turn tool-call\n\t\t// sessions work fine without it, saving ~18 tokens/turn). The session\n\t\t// still keeps it (for display/archive); we just don't pay to re-upload it.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\tout := chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n\tif c.deepseek {\n\t\tout.Thinking = &thinkingMode{Type: \"enabled\"}\n\t\tif c.effort == \"off\" {\n\t\t\tout.Thinking.Type = \"disabled\"\n\t\t\tout.ReasoningEffort = \"\"\n\t\t}\n\t}\n\treturn out\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging indefinitely on a stalled connection.\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tresp.Body.Close()\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n\tThinking *thinkingMode `json:\"thinking,omitempty\"`\n}\n\ntype thinkingMode struct {\n\tType string `json:\"type\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "379b6e84952ba0ebb406a563b58cfed819d1f166": {"t": 1785750717.5254772, "n": 15056, "h": "040f7663f003b69d", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\teffort: effort,\n\t\thttp: &http.Client{\n\t\t\tTransport: &http.Transport{\n\t\t\t\tDialContext: (&net.Dialer{\n\t\t\t\t\tTimeout: 30 * time.Second,\n\t\t\t\t\tKeepAlive: 30 * time.Second,\n\t\t\t\t}).DialContext,\n\t\t\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\t\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t\t\t},\n\t\t},\n\t}, nil\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\n\tresp, err := c.sendWithRetry(ctx, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(ctx, resp, out)\n\treturn out, nil\n}\n\n// sendWithRetry POSTs the request body and returns the streaming response,\n// retrying on transient network errors and retryable HTTP statuses (408, 429,\n// 5xx) with exponential backoff + jitter. Retries only cover the connection +\n// header phase; once we hand the response to readStream, mid-stream failures\n// surface as ChunkError without retry, since the model has already started\n// emitting tokens we'd otherwise duplicate.\nfunc (c *client) sendWithRetry(ctx context.Context, body []byte) (*http.Response, error) {\n\tconst maxAttempts = 3\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tif attempt > 0 {\n\t\t\tdelay := time.Duration(1<<(attempt-1))*500*time.Millisecond + time.Duration(rand.Intn(250))*time.Millisecond\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(delay):\n\t\t\t}\n\t\t}\n\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%s: build request: %w\", c.name, err)\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\n\t\tresp, err := c.http.Do(httpReq)\n\t\tif err != nil {\n\t\t\tif !isTransientErr(err) {\n\t\t\t\treturn nil, fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\t}\n\t\t\tlastErr = fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\tcontinue\n\t\t}\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tmsg, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))\n\t\tif readErr != nil {\n\t\t\tmsg = []byte(fmt.Sprintf(\"(could not read error body: %v)\", readErr))\n\t\t}\n\t\t// Drain any remaining body so the HTTP connection can be reused by the\n\t\t// transport pool, then close.\n\t\t_, _ = io.Copy(io.Discard, resp.Body)\n\t\tresp.Body.Close()\n\t\t// A rejected key is a configuration problem, not a transient one — give\n\t\t// an actionable error instead of dumping the raw status body.\n\t\tif resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {\n\t\t\treturn nil, &provider.AuthError{Provider: c.name, KeyEnv: c.keyEnv, Status: resp.StatusCode}\n\t\t}\n\t\tstatusErr := fmt.Errorf(\"%s: status %d: %s\", c.name, resp.StatusCode, strings.TrimSpace(string(msg)))\n\t\tif !isRetryableStatus(resp.StatusCode) {\n\t\t\treturn nil, statusErr\n\t\t}\n\t\tlastErr = statusErr\n\t}\n\treturn nil, lastErr\n}\n\n// isRetryableStatus returns true for HTTP status codes a transient backoff can\n// reasonably recover from: 408 (request timeout), 429 (rate limit), and 5xx.\n// 4xx other than 408/429 (auth, validation, not-found) are caller bugs and\n// won't fix themselves on retry.\nfunc isRetryableStatus(s int) bool {\n\treturn s == http.StatusRequestTimeout || s == http.StatusTooManyRequests || (s >= 500 && s <= 599)\n}\n\n// isTransientErr classifies HTTP client errors. ctx cancellation and deadline\n// expiry are caller intent — never retry those. Everything else (DNS failures,\n// connection resets, abrupt EOF, etc.) gets one more shot.\nfunc isTransientErr(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek counts re-sent reasoning as billable prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain); MiMo accepts\n\t\t// it but does not require it (verified empirically: multi-turn tool-call\n\t\t// sessions work fine without it, saving ~18 tokens/turn). The session\n\t\t// still keeps it (for display/archive); we just don't pay to re-upload it.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\treturn chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging indefinitely on a stalled connection.\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tresp.Body.Close()\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "be584c8c70361a77c4fb8a8b15b5893bd4cbf306": {"t": 1785750718.6606996, "n": 14997, "h": "75acc36753ada79b", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\teffort: effort,\n\t\thttp: &http.Client{\n\t\t\tTransport: &http.Transport{\n\t\t\t\tDialContext: (&net.Dialer{\n\t\t\t\t\tTimeout: 30 * time.Second,\n\t\t\t\t\tKeepAlive: 30 * time.Second,\n\t\t\t\t}).DialContext,\n\t\t\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\t\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t\t\t},\n\t\t},\n\t}, nil\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\n\tresp, err := c.sendWithRetry(ctx, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(ctx, resp, out)\n\treturn out, nil\n}\n\n// sendWithRetry POSTs the request body and returns the streaming response,\n// retrying on transient network errors and retryable HTTP statuses (408, 429,\n// 5xx) with exponential backoff + jitter. Retries only cover the connection +\n// header phase; once we hand the response to readStream, mid-stream failures\n// surface as ChunkError without retry, since the model has already started\n// emitting tokens we'd otherwise duplicate.\nfunc (c *client) sendWithRetry(ctx context.Context, body []byte) (*http.Response, error) {\n\tconst maxAttempts = 3\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tif attempt > 0 {\n\t\t\tdelay := time.Duration(1<<(attempt-1))*500*time.Millisecond + time.Duration(rand.Intn(250))*time.Millisecond\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(delay):\n\t\t\t}\n\t\t}\n\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%s: build request: %w\", c.name, err)\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\n\t\tresp, err := c.http.Do(httpReq)\n\t\tif err != nil {\n\t\t\tif !isTransientErr(err) {\n\t\t\t\treturn nil, fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\t}\n\t\t\tlastErr = fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\tcontinue\n\t\t}\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tmsg, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))\n\t\tif readErr != nil {\n\t\t\tmsg = []byte(fmt.Sprintf(\"(could not read error body: %v)\", readErr))\n\t\t}\n\t\t// Drain any remaining body so the HTTP connection can be reused by the\n\t\t// transport pool, then close.\n\t\t_, _ = io.Copy(io.Discard, resp.Body)\n\t\tresp.Body.Close()\n\t\t// A rejected key is a configuration problem, not a transient one — give\n\t\t// an actionable error instead of dumping the raw status body.\n\t\tif resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {\n\t\t\treturn nil, &provider.AuthError{Provider: c.name, KeyEnv: c.keyEnv, Status: resp.StatusCode}\n\t\t}\n\t\tstatusErr := fmt.Errorf(\"%s: status %d: %s\", c.name, resp.StatusCode, strings.TrimSpace(string(msg)))\n\t\tif !isRetryableStatus(resp.StatusCode) {\n\t\t\treturn nil, statusErr\n\t\t}\n\t\tlastErr = statusErr\n\t}\n\treturn nil, lastErr\n}\n\n// isRetryableStatus returns true for HTTP status codes a transient backoff can\n// reasonably recover from: 408 (request timeout), 429 (rate limit), and 5xx.\n// 4xx other than 408/429 (auth, validation, not-found) are caller bugs and\n// won't fix themselves on retry.\nfunc isRetryableStatus(s int) bool {\n\treturn s == http.StatusRequestTimeout || s == http.StatusTooManyRequests || (s >= 500 && s <= 599)\n}\n\n// isTransientErr classifies HTTP client errors. ctx cancellation and deadline\n// expiry are caller intent — never retry those. Everything else (DNS failures,\n// connection resets, abrupt EOF, etc.) gets one more shot.\nfunc isTransientErr(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek accepts it but counts it as ordinary prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain), and the\n\t\t// OpenAI-compatible convention is not to echo it. The session still keeps\n\t\t// it (for display/archive); we just don't pay to re-upload it every turn.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\treturn chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging indefinitely on a stalled connection.\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tresp.Body.Close()\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\ttc := acc[idx]\n\t\tif tc.ID == \"\" {\n\t\t\t// Some OpenAI-compatible gateways stream tool calls by index with no id.\n\t\t\t// Synthesize a stable one so the result can be paired back to its call —\n\t\t\t// an empty tool_call_id collapses multi-tool turns downstream.\n\t\t\ttc.ID = fmt.Sprintf(\"call_%d\", idx)\n\t\t}\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: tc}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream (see buildRequest) — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "7f67c065bec5c79f46eee1f4f621beaf246dd82d": {"t": 1785750719.792716, "n": 14703, "h": "55e18ddefbcef5ef", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\teffort: effort,\n\t\thttp: &http.Client{\n\t\t\tTransport: &http.Transport{\n\t\t\t\tDialContext: (&net.Dialer{\n\t\t\t\t\tTimeout: 30 * time.Second,\n\t\t\t\t\tKeepAlive: 30 * time.Second,\n\t\t\t\t}).DialContext,\n\t\t\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\t\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t\t\t},\n\t\t},\n\t}, nil\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\n\tresp, err := c.sendWithRetry(ctx, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(ctx, resp, out)\n\treturn out, nil\n}\n\n// sendWithRetry POSTs the request body and returns the streaming response,\n// retrying on transient network errors and retryable HTTP statuses (408, 429,\n// 5xx) with exponential backoff + jitter. Retries only cover the connection +\n// header phase; once we hand the response to readStream, mid-stream failures\n// surface as ChunkError without retry, since the model has already started\n// emitting tokens we'd otherwise duplicate.\nfunc (c *client) sendWithRetry(ctx context.Context, body []byte) (*http.Response, error) {\n\tconst maxAttempts = 3\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tif attempt > 0 {\n\t\t\tdelay := time.Duration(1<<(attempt-1))*500*time.Millisecond + time.Duration(rand.Intn(250))*time.Millisecond\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(delay):\n\t\t\t}\n\t\t}\n\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%s: build request: %w\", c.name, err)\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\n\t\tresp, err := c.http.Do(httpReq)\n\t\tif err != nil {\n\t\t\tif !isTransientErr(err) {\n\t\t\t\treturn nil, fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\t}\n\t\t\tlastErr = fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\tcontinue\n\t\t}\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tmsg, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))\n\t\tif readErr != nil {\n\t\t\tmsg = []byte(fmt.Sprintf(\"(could not read error body: %v)\", readErr))\n\t\t}\n\t\t// Drain any remaining body so the HTTP connection can be reused by the\n\t\t// transport pool, then close.\n\t\t_, _ = io.Copy(io.Discard, resp.Body)\n\t\tresp.Body.Close()\n\t\t// A rejected key is a configuration problem, not a transient one — give\n\t\t// an actionable error instead of dumping the raw status body.\n\t\tif resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {\n\t\t\treturn nil, &provider.AuthError{Provider: c.name, KeyEnv: c.keyEnv, Status: resp.StatusCode}\n\t\t}\n\t\tstatusErr := fmt.Errorf(\"%s: status %d: %s\", c.name, resp.StatusCode, strings.TrimSpace(string(msg)))\n\t\tif !isRetryableStatus(resp.StatusCode) {\n\t\t\treturn nil, statusErr\n\t\t}\n\t\tlastErr = statusErr\n\t}\n\treturn nil, lastErr\n}\n\n// isRetryableStatus returns true for HTTP status codes a transient backoff can\n// reasonably recover from: 408 (request timeout), 429 (rate limit), and 5xx.\n// 4xx other than 408/429 (auth, validation, not-found) are caller bugs and\n// won't fix themselves on retry.\nfunc isRetryableStatus(s int) bool {\n\treturn s == http.StatusRequestTimeout || s == http.StatusTooManyRequests || (s >= 500 && s <= 599)\n}\n\n// isTransientErr classifies HTTP client errors. ctx cancellation and deadline\n// expiry are caller intent — never retry those. Everything else (DNS failures,\n// connection resets, abrupt EOF, etc.) gets one more shot.\nfunc isTransientErr(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\t// Repair tool-call pairing before sending: an interrupted/resumed history can\n\t// carry an assistant tool_calls turn whose results never landed, which DeepSeek\n\t// rejects with a 400 (\"must be followed by tool messages …\").\n\tsrc := provider.SanitizeToolPairing(req.Messages)\n\tmsgs := make([]chatMessage, len(src))\n\tfor i, m := range src {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek accepts it but counts it as ordinary prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain), and the\n\t\t// OpenAI-compatible convention is not to echo it. The session still keeps\n\t\t// it (for display/archive); we just don't pay to re-upload it every turn.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\treturn chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging indefinitely on a stalled connection.\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tresp.Body.Close()\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: acc[idx]}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream (see buildRequest) — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "19bf1872e01f653efca632248effeb42b08d44aa": {"t": 1785750720.9194674, "n": 14444, "h": "a9d908f8510ddf61", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\teffort: effort,\n\t\thttp: &http.Client{\n\t\t\tTransport: &http.Transport{\n\t\t\t\tDialContext: (&net.Dialer{\n\t\t\t\t\tTimeout: 30 * time.Second,\n\t\t\t\t\tKeepAlive: 30 * time.Second,\n\t\t\t\t}).DialContext,\n\t\t\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\t\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t\t\t},\n\t\t},\n\t}, nil\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\n\tresp, err := c.sendWithRetry(ctx, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(ctx, resp, out)\n\treturn out, nil\n}\n\n// sendWithRetry POSTs the request body and returns the streaming response,\n// retrying on transient network errors and retryable HTTP statuses (408, 429,\n// 5xx) with exponential backoff + jitter. Retries only cover the connection +\n// header phase; once we hand the response to readStream, mid-stream failures\n// surface as ChunkError without retry, since the model has already started\n// emitting tokens we'd otherwise duplicate.\nfunc (c *client) sendWithRetry(ctx context.Context, body []byte) (*http.Response, error) {\n\tconst maxAttempts = 3\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tif attempt > 0 {\n\t\t\tdelay := time.Duration(1<<(attempt-1))*500*time.Millisecond + time.Duration(rand.Intn(250))*time.Millisecond\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(delay):\n\t\t\t}\n\t\t}\n\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%s: build request: %w\", c.name, err)\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\n\t\tresp, err := c.http.Do(httpReq)\n\t\tif err != nil {\n\t\t\tif !isTransientErr(err) {\n\t\t\t\treturn nil, fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\t}\n\t\t\tlastErr = fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\tcontinue\n\t\t}\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tmsg, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))\n\t\tif readErr != nil {\n\t\t\tmsg = []byte(fmt.Sprintf(\"(could not read error body: %v)\", readErr))\n\t\t}\n\t\t// Drain any remaining body so the HTTP connection can be reused by the\n\t\t// transport pool, then close.\n\t\t_, _ = io.Copy(io.Discard, resp.Body)\n\t\tresp.Body.Close()\n\t\t// A rejected key is a configuration problem, not a transient one — give\n\t\t// an actionable error instead of dumping the raw status body.\n\t\tif resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {\n\t\t\treturn nil, &provider.AuthError{Provider: c.name, KeyEnv: c.keyEnv, Status: resp.StatusCode}\n\t\t}\n\t\tstatusErr := fmt.Errorf(\"%s: status %d: %s\", c.name, resp.StatusCode, strings.TrimSpace(string(msg)))\n\t\tif !isRetryableStatus(resp.StatusCode) {\n\t\t\treturn nil, statusErr\n\t\t}\n\t\tlastErr = statusErr\n\t}\n\treturn nil, lastErr\n}\n\n// isRetryableStatus returns true for HTTP status codes a transient backoff can\n// reasonably recover from: 408 (request timeout), 429 (rate limit), and 5xx.\n// 4xx other than 408/429 (auth, validation, not-found) are caller bugs and\n// won't fix themselves on retry.\nfunc isRetryableStatus(s int) bool {\n\treturn s == http.StatusRequestTimeout || s == http.StatusTooManyRequests || (s >= 500 && s <= 599)\n}\n\n// isTransientErr classifies HTTP client errors. ctx cancellation and deadline\n// expiry are caller intent — never retry those. Everything else (DNS failures,\n// connection resets, abrupt EOF, etc.) gets one more shot.\nfunc isTransientErr(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\tmsgs := make([]chatMessage, len(req.Messages))\n\tfor i, m := range req.Messages {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek accepts it but counts it as ordinary prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain), and the\n\t\t// OpenAI-compatible convention is not to echo it. The session still keeps\n\t\t// it (for display/archive); we just don't pay to re-upload it every turn.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\treturn chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging indefinitely on a stalled connection.\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tresp.Body.Close()\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\tvar think thinkSplitter\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tr, txt := think.push(delta.Content)\n\t\t\tif r != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t\t}\n\t\t\tif txt != \"\" {\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t\t}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tif r, txt := think.flush(); r != \"\" || txt != \"\" {\n\t\tif r != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: r}\n\t\t}\n\t\tif txt != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: txt}\n\t\t}\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: acc[idx]}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream (see buildRequest) — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "09a941cc937b1cf7dafc03021910ccf1b87844c8": {"t": 1785750722.2166119, "n": 14056, "h": "7bb2ed9e130ff981", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\teffort, _ := cfg.Extra[\"effort\"].(string)\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\teffort: effort,\n\t\thttp: &http.Client{\n\t\t\tTransport: &http.Transport{\n\t\t\t\tDialContext: (&net.Dialer{\n\t\t\t\t\tTimeout: 30 * time.Second,\n\t\t\t\t\tKeepAlive: 30 * time.Second,\n\t\t\t\t}).DialContext,\n\t\t\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\t\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t\t\t},\n\t\t},\n\t}, nil\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n\teffort string // reasoning_effort forwarded to thinking-capable models; \"\" = omit\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\n\tresp, err := c.sendWithRetry(ctx, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(ctx, resp, out)\n\treturn out, nil\n}\n\n// sendWithRetry POSTs the request body and returns the streaming response,\n// retrying on transient network errors and retryable HTTP statuses (408, 429,\n// 5xx) with exponential backoff + jitter. Retries only cover the connection +\n// header phase; once we hand the response to readStream, mid-stream failures\n// surface as ChunkError without retry, since the model has already started\n// emitting tokens we'd otherwise duplicate.\nfunc (c *client) sendWithRetry(ctx context.Context, body []byte) (*http.Response, error) {\n\tconst maxAttempts = 3\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tif attempt > 0 {\n\t\t\tdelay := time.Duration(1<<(attempt-1))*500*time.Millisecond + time.Duration(rand.Intn(250))*time.Millisecond\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(delay):\n\t\t\t}\n\t\t}\n\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%s: build request: %w\", c.name, err)\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\n\t\tresp, err := c.http.Do(httpReq)\n\t\tif err != nil {\n\t\t\tif !isTransientErr(err) {\n\t\t\t\treturn nil, fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\t}\n\t\t\tlastErr = fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\tcontinue\n\t\t}\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tmsg, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))\n\t\tif readErr != nil {\n\t\t\tmsg = []byte(fmt.Sprintf(\"(could not read error body: %v)\", readErr))\n\t\t}\n\t\t// Drain any remaining body so the HTTP connection can be reused by the\n\t\t// transport pool, then close.\n\t\t_, _ = io.Copy(io.Discard, resp.Body)\n\t\tresp.Body.Close()\n\t\t// A rejected key is a configuration problem, not a transient one — give\n\t\t// an actionable error instead of dumping the raw status body.\n\t\tif resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {\n\t\t\treturn nil, &provider.AuthError{Provider: c.name, KeyEnv: c.keyEnv, Status: resp.StatusCode}\n\t\t}\n\t\tstatusErr := fmt.Errorf(\"%s: status %d: %s\", c.name, resp.StatusCode, strings.TrimSpace(string(msg)))\n\t\tif !isRetryableStatus(resp.StatusCode) {\n\t\t\treturn nil, statusErr\n\t\t}\n\t\tlastErr = statusErr\n\t}\n\treturn nil, lastErr\n}\n\n// isRetryableStatus returns true for HTTP status codes a transient backoff can\n// reasonably recover from: 408 (request timeout), 429 (rate limit), and 5xx.\n// 4xx other than 408/429 (auth, validation, not-found) are caller bugs and\n// won't fix themselves on retry.\nfunc isRetryableStatus(s int) bool {\n\treturn s == http.StatusRequestTimeout || s == http.StatusTooManyRequests || (s >= 500 && s <= 599)\n}\n\n// isTransientErr classifies HTTP client errors. ctx cancellation and deadline\n// expiry are caller intent — never retry those. Everything else (DNS failures,\n// connection resets, abrupt EOF, etc.) gets one more shot.\nfunc isTransientErr(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\tmsgs := make([]chatMessage, len(req.Messages))\n\tfor i, m := range req.Messages {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek accepts it but counts it as ordinary prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain), and the\n\t\t// OpenAI-compatible convention is not to echo it. The session still keeps\n\t\t// it (for display/archive); we just don't pay to re-upload it every turn.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\treturn chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t\tReasoningEffort: c.effort,\n\t}\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging indefinitely on a stalled connection.\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tresp.Body.Close()\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: delta.Content}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: acc[idx]}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n\tReasoningEffort string `json:\"reasoning_effort,omitempty\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream (see buildRequest) — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "021df8651840eb67b4c09289c21abdb2e847ea5c": {"t": 1785750723.354405, "n": 13785, "h": "c160326873309977", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\thttp: &http.Client{\n\t\t\tTransport: &http.Transport{\n\t\t\t\tDialContext: (&net.Dialer{\n\t\t\t\t\tTimeout: 30 * time.Second,\n\t\t\t\t\tKeepAlive: 30 * time.Second,\n\t\t\t\t}).DialContext,\n\t\t\t\tTLSHandshakeTimeout: 15 * time.Second,\n\t\t\t\tResponseHeaderTimeout: 120 * time.Second, // models can think for a while before the first token\n\t\t\t},\n\t\t},\n\t}, nil\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\n\tresp, err := c.sendWithRetry(ctx, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(ctx, resp, out)\n\treturn out, nil\n}\n\n// sendWithRetry POSTs the request body and returns the streaming response,\n// retrying on transient network errors and retryable HTTP statuses (408, 429,\n// 5xx) with exponential backoff + jitter. Retries only cover the connection +\n// header phase; once we hand the response to readStream, mid-stream failures\n// surface as ChunkError without retry, since the model has already started\n// emitting tokens we'd otherwise duplicate.\nfunc (c *client) sendWithRetry(ctx context.Context, body []byte) (*http.Response, error) {\n\tconst maxAttempts = 3\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tif attempt > 0 {\n\t\t\tdelay := time.Duration(1<<(attempt-1))*500*time.Millisecond + time.Duration(rand.Intn(250))*time.Millisecond\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(delay):\n\t\t\t}\n\t\t}\n\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%s: build request: %w\", c.name, err)\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\n\t\tresp, err := c.http.Do(httpReq)\n\t\tif err != nil {\n\t\t\tif !isTransientErr(err) {\n\t\t\t\treturn nil, fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\t}\n\t\t\tlastErr = fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\tcontinue\n\t\t}\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tmsg, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))\n\t\tif readErr != nil {\n\t\t\tmsg = []byte(fmt.Sprintf(\"(could not read error body: %v)\", readErr))\n\t\t}\n\t\t// Drain any remaining body so the HTTP connection can be reused by the\n\t\t// transport pool, then close.\n\t\t_, _ = io.Copy(io.Discard, resp.Body)\n\t\tresp.Body.Close()\n\t\t// A rejected key is a configuration problem, not a transient one — give\n\t\t// an actionable error instead of dumping the raw status body.\n\t\tif resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {\n\t\t\treturn nil, &provider.AuthError{Provider: c.name, KeyEnv: c.keyEnv, Status: resp.StatusCode}\n\t\t}\n\t\tstatusErr := fmt.Errorf(\"%s: status %d: %s\", c.name, resp.StatusCode, strings.TrimSpace(string(msg)))\n\t\tif !isRetryableStatus(resp.StatusCode) {\n\t\t\treturn nil, statusErr\n\t\t}\n\t\tlastErr = statusErr\n\t}\n\treturn nil, lastErr\n}\n\n// isRetryableStatus returns true for HTTP status codes a transient backoff can\n// reasonably recover from: 408 (request timeout), 429 (rate limit), and 5xx.\n// 4xx other than 408/429 (auth, validation, not-found) are caller bugs and\n// won't fix themselves on retry.\nfunc isRetryableStatus(s int) bool {\n\treturn s == http.StatusRequestTimeout || s == http.StatusTooManyRequests || (s >= 500 && s <= 599)\n}\n\n// isTransientErr classifies HTTP client errors. ctx cancellation and deadline\n// expiry are caller intent — never retry those. Everything else (DNS failures,\n// connection resets, abrupt EOF, etc.) gets one more shot.\nfunc isTransientErr(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\tmsgs := make([]chatMessage, len(req.Messages))\n\tfor i, m := range req.Messages {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek accepts it but counts it as ordinary prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain), and the\n\t\t// OpenAI-compatible convention is not to echo it. The session still keeps\n\t\t// it (for display/archive); we just don't pay to re-upload it every turn.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\treturn chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t}\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(ctx context.Context, resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\t// Close the response body when the context is canceled so scanner.Scan()\n\t// unblocks instead of hanging indefinitely on a stalled connection.\n\tgo func() {\n\t\t<-ctx.Done()\n\t\tresp.Body.Close()\n\t}()\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: delta.Content}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: acc[idx]}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream (see buildRequest) — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "2987bc8a21509a38ed9fcb3f078b6714e863c01f": {"t": 1785750724.4673042, "n": 13298, "h": "b1112d91d1de99b1", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\thttp: &http.Client{}, // no overall timeout; lifecycle is ctx-driven\n\t}, nil\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\n\tresp, err := c.sendWithRetry(ctx, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(resp, out)\n\treturn out, nil\n}\n\n// sendWithRetry POSTs the request body and returns the streaming response,\n// retrying on transient network errors and retryable HTTP statuses (408, 429,\n// 5xx) with exponential backoff + jitter. Retries only cover the connection +\n// header phase; once we hand the response to readStream, mid-stream failures\n// surface as ChunkError without retry, since the model has already started\n// emitting tokens we'd otherwise duplicate.\nfunc (c *client) sendWithRetry(ctx context.Context, body []byte) (*http.Response, error) {\n\tconst maxAttempts = 3\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tif attempt > 0 {\n\t\t\tdelay := time.Duration(1<<(attempt-1))*500*time.Millisecond + time.Duration(rand.Intn(250))*time.Millisecond\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(delay):\n\t\t\t}\n\t\t}\n\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%s: build request: %w\", c.name, err)\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\n\t\tresp, err := c.http.Do(httpReq)\n\t\tif err != nil {\n\t\t\tif !isTransientErr(err) {\n\t\t\t\treturn nil, fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\t}\n\t\t\tlastErr = fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\tcontinue\n\t\t}\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tmsg, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))\n\t\tif readErr != nil {\n\t\t\tmsg = []byte(fmt.Sprintf(\"(could not read error body: %v)\", readErr))\n\t\t}\n\t\t// Drain any remaining body so the HTTP connection can be reused by the\n\t\t// transport pool, then close.\n\t\t_, _ = io.Copy(io.Discard, resp.Body)\n\t\tresp.Body.Close()\n\t\t// A rejected key is a configuration problem, not a transient one — give\n\t\t// an actionable error instead of dumping the raw status body.\n\t\tif resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {\n\t\t\treturn nil, &provider.AuthError{Provider: c.name, KeyEnv: c.keyEnv, Status: resp.StatusCode}\n\t\t}\n\t\tstatusErr := fmt.Errorf(\"%s: status %d: %s\", c.name, resp.StatusCode, strings.TrimSpace(string(msg)))\n\t\tif !isRetryableStatus(resp.StatusCode) {\n\t\t\treturn nil, statusErr\n\t\t}\n\t\tlastErr = statusErr\n\t}\n\treturn nil, lastErr\n}\n\n// isRetryableStatus returns true for HTTP status codes a transient backoff can\n// reasonably recover from: 408 (request timeout), 429 (rate limit), and 5xx.\n// 4xx other than 408/429 (auth, validation, not-found) are caller bugs and\n// won't fix themselves on retry.\nfunc isRetryableStatus(s int) bool {\n\treturn s == http.StatusRequestTimeout || s == http.StatusTooManyRequests || (s >= 500 && s <= 599)\n}\n\n// isTransientErr classifies HTTP client errors. ctx cancellation and deadline\n// expiry are caller intent — never retry those. Everything else (DNS failures,\n// connection resets, abrupt EOF, etc.) gets one more shot.\nfunc isTransientErr(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\tmsgs := make([]chatMessage, len(req.Messages))\n\tfor i, m := range req.Messages {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek accepts it but counts it as ordinary prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain), and the\n\t\t// OpenAI-compatible convention is not to echo it. The session still keeps\n\t\t// it (for display/archive); we just don't pay to re-upload it every turn.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\treturn chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t}\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: delta.Content}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: acc[idx]}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream (see buildRequest) — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "d84fbabd23585b0045f7e463fb2f4df0a7cb25e6": {"t": 1785750725.9767308, "n": 13193, "h": "3fd55cdeb1ed1a81", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\thttp: &http.Client{}, // no overall timeout; lifecycle is ctx-driven\n\t}, nil\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\n\tresp, err := c.sendWithRetry(ctx, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(resp, out)\n\treturn out, nil\n}\n\n// sendWithRetry POSTs the request body and returns the streaming response,\n// retrying on transient network errors and retryable HTTP statuses (408, 429,\n// 5xx) with exponential backoff + jitter. Retries only cover the connection +\n// header phase; once we hand the response to readStream, mid-stream failures\n// surface as ChunkError without retry, since the model has already started\n// emitting tokens we'd otherwise duplicate.\nfunc (c *client) sendWithRetry(ctx context.Context, body []byte) (*http.Response, error) {\n\tconst maxAttempts = 3\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tif attempt > 0 {\n\t\t\tdelay := time.Duration(1<<(attempt-1))*500*time.Millisecond + time.Duration(rand.Intn(250))*time.Millisecond\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(delay):\n\t\t\t}\n\t\t}\n\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%s: build request: %w\", c.name, err)\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\n\t\tresp, err := c.http.Do(httpReq)\n\t\tif err != nil {\n\t\t\tif !isTransientErr(err) {\n\t\t\t\treturn nil, fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\t}\n\t\t\tlastErr = fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\tcontinue\n\t\t}\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tmsg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))\n\t\t// Drain any remaining body so the HTTP connection can be reused by the\n\t\t// transport pool, then close.\n\t\t_, _ = io.Copy(io.Discard, resp.Body)\n\t\tresp.Body.Close()\n\t\t// A rejected key is a configuration problem, not a transient one — give\n\t\t// an actionable error instead of dumping the raw status body.\n\t\tif resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {\n\t\t\treturn nil, &provider.AuthError{Provider: c.name, KeyEnv: c.keyEnv, Status: resp.StatusCode}\n\t\t}\n\t\tstatusErr := fmt.Errorf(\"%s: status %d: %s\", c.name, resp.StatusCode, strings.TrimSpace(string(msg)))\n\t\tif !isRetryableStatus(resp.StatusCode) {\n\t\t\treturn nil, statusErr\n\t\t}\n\t\tlastErr = statusErr\n\t}\n\treturn nil, lastErr\n}\n\n// isRetryableStatus returns true for HTTP status codes a transient backoff can\n// reasonably recover from: 408 (request timeout), 429 (rate limit), and 5xx.\n// 4xx other than 408/429 (auth, validation, not-found) are caller bugs and\n// won't fix themselves on retry.\nfunc isRetryableStatus(s int) bool {\n\treturn s == http.StatusRequestTimeout || s == http.StatusTooManyRequests || (s >= 500 && s <= 599)\n}\n\n// isTransientErr classifies HTTP client errors. ctx cancellation and deadline\n// expiry are caller intent — never retry those. Everything else (DNS failures,\n// connection resets, abrupt EOF, etc.) gets one more shot.\nfunc isTransientErr(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\tmsgs := make([]chatMessage, len(req.Messages))\n\tfor i, m := range req.Messages {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek accepts it but counts it as ordinary prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain), and the\n\t\t// OpenAI-compatible convention is not to echo it. The session still keeps\n\t\t// it (for display/archive); we just don't pay to re-upload it every turn.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\treturn chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t}\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: delta.Content}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: acc[idx]}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream (see buildRequest) — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "b5d07bf29b0cd8517720980cba86f705aed7d65d": {"t": 1785750727.067009, "n": 13046, "h": "b1d3a8a21ccbe963", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\thttp: &http.Client{}, // no overall timeout; lifecycle is ctx-driven\n\t}, nil\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\n\tresp, err := c.sendWithRetry(ctx, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(resp, out)\n\treturn out, nil\n}\n\n// sendWithRetry POSTs the request body and returns the streaming response,\n// retrying on transient network errors and retryable HTTP statuses (408, 429,\n// 5xx) with exponential backoff + jitter. Retries only cover the connection +\n// header phase; once we hand the response to readStream, mid-stream failures\n// surface as ChunkError without retry, since the model has already started\n// emitting tokens we'd otherwise duplicate.\nfunc (c *client) sendWithRetry(ctx context.Context, body []byte) (*http.Response, error) {\n\tconst maxAttempts = 3\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tif attempt > 0 {\n\t\t\tdelay := time.Duration(1<<(attempt-1))*500*time.Millisecond + time.Duration(rand.Intn(250))*time.Millisecond\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(delay):\n\t\t\t}\n\t\t}\n\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%s: build request: %w\", c.name, err)\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\n\t\tresp, err := c.http.Do(httpReq)\n\t\tif err != nil {\n\t\t\tif !isTransientErr(err) {\n\t\t\t\treturn nil, fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\t}\n\t\t\tlastErr = fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\tcontinue\n\t\t}\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tmsg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))\n\t\tresp.Body.Close()\n\t\t// A rejected key is a configuration problem, not a transient one — give\n\t\t// an actionable error instead of dumping the raw status body.\n\t\tif resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {\n\t\t\treturn nil, &provider.AuthError{Provider: c.name, KeyEnv: c.keyEnv, Status: resp.StatusCode}\n\t\t}\n\t\tstatusErr := fmt.Errorf(\"%s: status %d: %s\", c.name, resp.StatusCode, strings.TrimSpace(string(msg)))\n\t\tif !isRetryableStatus(resp.StatusCode) {\n\t\t\treturn nil, statusErr\n\t\t}\n\t\tlastErr = statusErr\n\t}\n\treturn nil, lastErr\n}\n\n// isRetryableStatus returns true for HTTP status codes a transient backoff can\n// reasonably recover from: 408 (request timeout), 429 (rate limit), and 5xx.\n// 4xx other than 408/429 (auth, validation, not-found) are caller bugs and\n// won't fix themselves on retry.\nfunc isRetryableStatus(s int) bool {\n\treturn s == http.StatusRequestTimeout || s == http.StatusTooManyRequests || (s >= 500 && s <= 599)\n}\n\n// isTransientErr classifies HTTP client errors. ctx cancellation and deadline\n// expiry are caller intent — never retry those. Everything else (DNS failures,\n// connection resets, abrupt EOF, etc.) gets one more shot.\nfunc isTransientErr(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\tmsgs := make([]chatMessage, len(req.Messages))\n\tfor i, m := range req.Messages {\n\t\t// reasoning_content is deliberately NOT sent back: it's a response-only\n\t\t// field. DeepSeek accepts it but counts it as ordinary prompt input\n\t\t// (measured ~500 extra tokens per turn on a reasoner chain), and the\n\t\t// OpenAI-compatible convention is not to echo it. The session still keeps\n\t\t// it (for display/archive); we just don't pay to re-upload it every turn.\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\treturn chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t}\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: delta.Content}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: acc[idx]}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n\t// no reasoning_content field: it is a response-only signal and is never sent\n\t// back upstream (see buildRequest) — re-uploading it is paid prompt input.\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}, "d06c8a0e74aba052e896236420579456728e144c": {"t": 1785750728.176953, "n": 12677, "h": "d3ae66a9e44e52c9", "c": "// Package openai implements the OpenAI-compatible /chat/completions provider.\n// It self-registers under the \"openai\" kind, so DeepSeek, MiMo, and any other\n// OpenAI-compatible endpoint are just config instances rather than code.\npackage openai\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"context\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"math/rand\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/provider\"\n)\n\nfunc init() {\n\tprovider.Register(\"openai\", New)\n}\n\n// New builds an OpenAI-compatible provider from a resolved config.\nfunc New(cfg provider.Config) (provider.Provider, error) {\n\tif cfg.BaseURL == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: base_url is required for provider %q\", cfg.Name)\n\t}\n\tif cfg.Model == \"\" {\n\t\treturn nil, fmt.Errorf(\"openai: model is required for provider %q\", cfg.Name)\n\t}\n\tname := cfg.Name\n\tif name == \"\" {\n\t\tname = \"openai\"\n\t}\n\tkeyEnv, _ := cfg.Extra[\"api_key_env\"].(string) // for actionable auth errors\n\treturn &client{\n\t\tname: name,\n\t\tapiKey: cfg.APIKey,\n\t\tkeyEnv: keyEnv,\n\t\tbaseURL: strings.TrimRight(cfg.BaseURL, \"/\"),\n\t\tmodel: cfg.Model,\n\t\thttp: &http.Client{}, // no overall timeout; lifecycle is ctx-driven\n\t}, nil\n}\n\ntype client struct {\n\tname string\n\tapiKey string\n\tkeyEnv string // api_key_env name, surfaced in auth errors\n\tbaseURL string\n\tmodel string\n\thttp *http.Client\n}\n\nfunc (c *client) Name() string { return c.name }\n\nfunc (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {\n\tbody, err := json.Marshal(c.buildRequest(req))\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%s: marshal request: %w\", c.name, err)\n\t}\n\n\tresp, err := c.sendWithRetry(ctx, body)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tout := make(chan provider.Chunk)\n\tgo c.readStream(resp, out)\n\treturn out, nil\n}\n\n// sendWithRetry POSTs the request body and returns the streaming response,\n// retrying on transient network errors and retryable HTTP statuses (408, 429,\n// 5xx) with exponential backoff + jitter. Retries only cover the connection +\n// header phase; once we hand the response to readStream, mid-stream failures\n// surface as ChunkError without retry, since the model has already started\n// emitting tokens we'd otherwise duplicate.\nfunc (c *client) sendWithRetry(ctx context.Context, body []byte) (*http.Response, error) {\n\tconst maxAttempts = 3\n\tvar lastErr error\n\tfor attempt := 0; attempt < maxAttempts; attempt++ {\n\t\tif attempt > 0 {\n\t\t\tdelay := time.Duration(1<<(attempt-1))*500*time.Millisecond + time.Duration(rand.Intn(250))*time.Millisecond\n\t\t\tselect {\n\t\t\tcase <-ctx.Done():\n\t\t\t\treturn nil, ctx.Err()\n\t\t\tcase <-time.After(delay):\n\t\t\t}\n\t\t}\n\n\t\thttpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+\"/chat/completions\", bytes.NewReader(body))\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%s: build request: %w\", c.name, err)\n\t\t}\n\t\thttpReq.Header.Set(\"Content-Type\", \"application/json\")\n\t\thttpReq.Header.Set(\"Authorization\", \"Bearer \"+c.apiKey)\n\t\thttpReq.Header.Set(\"Accept\", \"text/event-stream\")\n\n\t\tresp, err := c.http.Do(httpReq)\n\t\tif err != nil {\n\t\t\tif !isTransientErr(err) {\n\t\t\t\treturn nil, fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\t}\n\t\t\tlastErr = fmt.Errorf(\"%s: request failed: %w\", c.name, err)\n\t\t\tcontinue\n\t\t}\n\t\tif resp.StatusCode == http.StatusOK {\n\t\t\treturn resp, nil\n\t\t}\n\t\tmsg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))\n\t\tresp.Body.Close()\n\t\t// A rejected key is a configuration problem, not a transient one — give\n\t\t// an actionable error instead of dumping the raw status body.\n\t\tif resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {\n\t\t\treturn nil, &provider.AuthError{Provider: c.name, KeyEnv: c.keyEnv, Status: resp.StatusCode}\n\t\t}\n\t\tstatusErr := fmt.Errorf(\"%s: status %d: %s\", c.name, resp.StatusCode, strings.TrimSpace(string(msg)))\n\t\tif !isRetryableStatus(resp.StatusCode) {\n\t\t\treturn nil, statusErr\n\t\t}\n\t\tlastErr = statusErr\n\t}\n\treturn nil, lastErr\n}\n\n// isRetryableStatus returns true for HTTP status codes a transient backoff can\n// reasonably recover from: 408 (request timeout), 429 (rate limit), and 5xx.\n// 4xx other than 408/429 (auth, validation, not-found) are caller bugs and\n// won't fix themselves on retry.\nfunc isRetryableStatus(s int) bool {\n\treturn s == http.StatusRequestTimeout || s == http.StatusTooManyRequests || (s >= 500 && s <= 599)\n}\n\n// isTransientErr classifies HTTP client errors. ctx cancellation and deadline\n// expiry are caller intent — never retry those. Everything else (DNS failures,\n// connection resets, abrupt EOF, etc.) gets one more shot.\nfunc isTransientErr(err error) bool {\n\tif err == nil {\n\t\treturn false\n\t}\n\tif errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (c *client) buildRequest(req provider.Request) chatRequest {\n\tmsgs := make([]chatMessage, len(req.Messages))\n\tfor i, m := range req.Messages {\n\t\tcm := chatMessage{\n\t\t\tRole: string(m.Role),\n\t\t\tContent: m.Content,\n\t\t\tReasoningContent: m.ReasoningContent,\n\t\t\tToolCallID: m.ToolCallID,\n\t\t\tName: m.Name,\n\t\t}\n\t\tfor _, tc := range m.ToolCalls {\n\t\t\twire := chatToolCall{ID: tc.ID, Type: \"function\"}\n\t\t\twire.Function.Name = tc.Name\n\t\t\twire.Function.Arguments = tc.Arguments\n\t\t\tcm.ToolCalls = append(cm.ToolCalls, wire)\n\t\t}\n\t\tmsgs[i] = cm\n\t}\n\n\tvar tools []chatTool\n\tfor _, t := range req.Tools {\n\t\ttools = append(tools, chatTool{\n\t\t\tType: \"function\",\n\t\t\tFunction: chatFunction{Name: t.Name, Description: t.Description, Parameters: t.Parameters},\n\t\t})\n\t}\n\n\treturn chatRequest{\n\t\tModel: c.model,\n\t\tMessages: msgs,\n\t\tTools: tools,\n\t\tStream: true,\n\t\tStreamOptions: &streamOptions{IncludeUsage: true},\n\t\tTemperature: req.Temperature,\n\t\tMaxTokens: req.MaxTokens,\n\t}\n}\n\n// readStream parses the SSE stream, emits text deltas live, accumulates tool-call\n// fragments internally, and emits complete ToolCalls (by index) when done. Each\n// call also gets a ChunkToolCallStart the moment its name is known, so a frontend\n// can show the tool card while the arguments are still streaming.\nfunc (c *client) readStream(resp *http.Response, out chan<- provider.Chunk) {\n\tdefer resp.Body.Close()\n\tdefer close(out)\n\n\tacc := map[int]*provider.ToolCall{}\n\tstarted := map[int]bool{}\n\tvar order []int\n\tvar lastFinishReason string\n\n\tscanner := bufio.NewScanner(resp.Body)\n\tscanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)\n\n\tfor scanner.Scan() {\n\t\tline := strings.TrimSpace(scanner.Text())\n\t\tif line == \"\" || !strings.HasPrefix(line, \"data:\") {\n\t\t\tcontinue\n\t\t}\n\t\tdata := strings.TrimSpace(strings.TrimPrefix(line, \"data:\"))\n\t\tif data == \"[DONE]\" {\n\t\t\tbreak\n\t\t}\n\n\t\tvar sr streamResponse\n\t\tif err := json.Unmarshal([]byte(data), &sr); err != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: decode stream: %w\", c.name, err)}\n\t\t\treturn\n\t\t}\n\t\tif sr.Error != nil {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: %s\", c.name, sr.Error.Message)}\n\t\t\treturn\n\t\t}\n\t\tif len(sr.Choices) > 0 && sr.Choices[0].FinishReason != nil && *sr.Choices[0].FinishReason != \"\" {\n\t\t\tlastFinishReason = *sr.Choices[0].FinishReason\n\t\t}\n\t\tif sr.Usage != nil {\n\t\t\tu := normaliseUsage(sr.Usage)\n\t\t\tu.FinishReason = lastFinishReason\n\t\t\tout <- provider.Chunk{Type: provider.ChunkUsage, Usage: u}\n\t\t}\n\t\tif len(sr.Choices) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tdelta := sr.Choices[0].Delta\n\t\tif delta.ReasoningContent != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkReasoning, Text: delta.ReasoningContent}\n\t\t}\n\t\tif delta.Content != \"\" {\n\t\t\tout <- provider.Chunk{Type: provider.ChunkText, Text: delta.Content}\n\t\t}\n\t\tfor _, tc := range delta.ToolCalls {\n\t\t\tcur, ok := acc[tc.Index]\n\t\t\tif !ok {\n\t\t\t\tcur = &provider.ToolCall{}\n\t\t\t\tacc[tc.Index] = cur\n\t\t\t\torder = append(order, tc.Index)\n\t\t\t}\n\t\t\tif tc.ID != \"\" {\n\t\t\t\tcur.ID = tc.ID\n\t\t\t}\n\t\t\tif tc.Function.Name != \"\" {\n\t\t\t\tcur.Name = tc.Function.Name\n\t\t\t}\n\t\t\tcur.Arguments += tc.Function.Arguments\n\t\t\t// Signal the call's start the moment its name is known, so a frontend\n\t\t\t// can show the tool card immediately rather than only after its\n\t\t\t// (possibly large) arguments finish streaming.\n\t\t\tif !started[tc.Index] && cur.Name != \"\" {\n\t\t\t\tstarted[tc.Index] = true\n\t\t\t\tout <- provider.Chunk{Type: provider.ChunkToolCallStart, ToolCall: &provider.ToolCall{ID: cur.ID, Name: cur.Name}}\n\t\t\t}\n\t\t}\n\t}\n\n\tif err := scanner.Err(); err != nil {\n\t\tout <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf(\"%s: read stream: %w\", c.name, err)}\n\t\treturn\n\t}\n\n\tsort.Ints(order)\n\tfor _, idx := range order {\n\t\tout <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: acc[idx]}\n\t}\n\tout <- provider.Chunk{Type: provider.ChunkDone}\n}\n\n// normaliseUsage folds the two cache-hit shapes the OpenAI-compatible ecosystem\n// uses into a single Usage: DeepSeek puts prompt_cache_{hit,miss}_tokens at the\n// top of usage; OpenAI and MiMo put it nested under prompt_tokens_details.\n// Whichever side reports non-zero wins; miss is derived when only hit is given.\n// Reasoning tokens land in completion_tokens_details on thinking-mode models.\nfunc normaliseUsage(u *wireUsage) *provider.Usage {\n\thit := u.PromptCacheHitTokens\n\tmiss := u.PromptCacheMissTokens\n\tif hit == 0 && u.PromptTokensDetails != nil {\n\t\thit = u.PromptTokensDetails.CachedTokens\n\t}\n\tif miss == 0 && hit > 0 && u.PromptTokens > hit {\n\t\tmiss = u.PromptTokens - hit\n\t}\n\treasoning := 0\n\tif u.CompletionTokensDetails != nil {\n\t\treasoning = u.CompletionTokensDetails.ReasoningTokens\n\t}\n\treturn &provider.Usage{\n\t\tPromptTokens: u.PromptTokens,\n\t\tCompletionTokens: u.CompletionTokens,\n\t\tTotalTokens: u.TotalTokens,\n\t\tCacheHitTokens: hit,\n\t\tCacheMissTokens: miss,\n\t\tReasoningTokens: reasoning,\n\t}\n}\n\n// --- OpenAI-compatible wire protocol ---\n\ntype chatRequest struct {\n\tModel string `json:\"model\"`\n\tMessages []chatMessage `json:\"messages\"`\n\tTools []chatTool `json:\"tools,omitempty\"`\n\tStream bool `json:\"stream\"`\n\tStreamOptions *streamOptions `json:\"stream_options,omitempty\"`\n\tTemperature float64 `json:\"temperature,omitempty\"`\n\tMaxTokens int `json:\"max_tokens,omitempty\"`\n}\n\ntype streamOptions struct {\n\tIncludeUsage bool `json:\"include_usage\"`\n}\n\ntype chatMessage struct {\n\tRole string `json:\"role\"`\n\t// content is always serialized, even when empty: an assistant turn that is\n\t// pure tool_calls (no preamble text) has empty content, and DeepSeek's\n\t// strict deserializer rejects a message missing the field (\"missing field\n\t// `content`\"). An empty string satisfies presence and is accepted by every\n\t// OpenAI-compatible backend for all roles (unlike null, which some reject\n\t// for a tool message).\n\tContent string `json:\"content\"`\n\tReasoningContent string `json:\"reasoning_content,omitempty\"`\n\tToolCalls []chatToolCall `json:\"tool_calls,omitempty\"`\n\tToolCallID string `json:\"tool_call_id,omitempty\"`\n\tName string `json:\"name,omitempty\"`\n}\n\ntype chatTool struct {\n\tType string `json:\"type\"`\n\tFunction chatFunction `json:\"function\"`\n}\n\ntype chatFunction struct {\n\tName string `json:\"name\"`\n\tDescription string `json:\"description\"`\n\tParameters json.RawMessage `json:\"parameters\"`\n}\n\ntype chatToolCall struct {\n\tIndex int `json:\"index\"`\n\tID string `json:\"id,omitempty\"`\n\tType string `json:\"type,omitempty\"`\n\tFunction struct {\n\t\tName string `json:\"name,omitempty\"`\n\t\tArguments string `json:\"arguments,omitempty\"`\n\t} `json:\"function\"`\n}\n\ntype streamResponse struct {\n\tChoices []struct {\n\t\tDelta struct {\n\t\t\tContent string `json:\"content\"`\n\t\t\tReasoningContent string `json:\"reasoning_content\"`\n\t\t\tToolCalls []chatToolCall `json:\"tool_calls\"`\n\t\t} `json:\"delta\"`\n\t\tFinishReason *string `json:\"finish_reason\"`\n\t} `json:\"choices\"`\n\tUsage *wireUsage `json:\"usage\"`\n\tError *struct {\n\t\tMessage string `json:\"message\"`\n\t} `json:\"error\"`\n}\n\n// wireUsage covers both DeepSeek's top-level cache fields and the\n// OpenAI/MiMo nested details — normaliseUsage chooses whichever side\n// reports values.\ntype wireUsage struct {\n\tPromptTokens int `json:\"prompt_tokens\"`\n\tCompletionTokens int `json:\"completion_tokens\"`\n\tTotalTokens int `json:\"total_tokens\"`\n\tPromptCacheHitTokens int `json:\"prompt_cache_hit_tokens\"`\n\tPromptCacheMissTokens int `json:\"prompt_cache_miss_tokens\"`\n\tPromptTokensDetails *struct {\n\t\tCachedTokens int `json:\"cached_tokens\"`\n\t} `json:\"prompt_tokens_details\"`\n\tCompletionTokensDetails *struct {\n\t\tReasoningTokens int `json:\"reasoning_tokens\"`\n\t} `json:\"completion_tokens_details\"`\n}\n"}}, "internal/config/proxy_test.go": {"c480afd7570fbb1fb96ff82a0ab117c5df7768e8": {"t": 1785746092.9370463, "n": 1017, "h": "b1126979cc919978", "c": "package config\n\nimport \"testing\"\n\nfunc TestDirectProxyHostsFromNoProxyProviders(t *testing.T) {\n\tspec := Default().NetworkProxySpec()\n\thasMimo := false\n\tfor _, h := range spec.DirectHosts {\n\t\tif h == \"token-plan-cn.xiaomimimo.com\" {\n\t\t\thasMimo = true\n\t\t}\n\t\tif h == \"api.deepseek.com\" {\n\t\t\tt.Errorf(\"DeepSeek works through the proxy and must not be forced direct: %v\", spec.DirectHosts)\n\t\t}\n\t}\n\tif !hasMimo {\n\t\tt.Errorf(\"a no_proxy provider's host should land in DirectHosts, got %v\", spec.DirectHosts)\n\t}\n}\n\nfunc TestExplicitProxyOverridesProviderNoProxy(t *testing.T) {\n\t// An explicit custom proxy (e.g. a mandatory corporate proxy) must apply to\n\t// every provider, including no_proxy ones like mimo, so it isn't unreachable\n\t// behind the proxy (#3635).\n\tc := Default()\n\tc.Network.ProxyMode = \"custom\"\n\tspec := c.NetworkProxySpec()\n\tfor _, h := range spec.DirectHosts {\n\t\tif h == \"token-plan-cn.xiaomimimo.com\" {\n\t\t\tt.Fatalf(\"custom proxy must not force mimo direct; DirectHosts = %v\", spec.DirectHosts)\n\t\t}\n\t}\n}\n"}, "4684e93fb2fb7deb68c6672af7b8429a8f3e1ae5": {"t": 1785750695.0835025, "n": 1017, "h": "b1126979cc919978", "c": "package config\n\nimport \"testing\"\n\nfunc TestDirectProxyHostsFromNoProxyProviders(t *testing.T) {\n\tspec := Default().NetworkProxySpec()\n\thasMimo := false\n\tfor _, h := range spec.DirectHosts {\n\t\tif h == \"token-plan-cn.xiaomimimo.com\" {\n\t\t\thasMimo = true\n\t\t}\n\t\tif h == \"api.deepseek.com\" {\n\t\t\tt.Errorf(\"DeepSeek works through the proxy and must not be forced direct: %v\", spec.DirectHosts)\n\t\t}\n\t}\n\tif !hasMimo {\n\t\tt.Errorf(\"a no_proxy provider's host should land in DirectHosts, got %v\", spec.DirectHosts)\n\t}\n}\n\nfunc TestExplicitProxyOverridesProviderNoProxy(t *testing.T) {\n\t// An explicit custom proxy (e.g. a mandatory corporate proxy) must apply to\n\t// every provider, including no_proxy ones like mimo, so it isn't unreachable\n\t// behind the proxy (#3635).\n\tc := Default()\n\tc.Network.ProxyMode = \"custom\"\n\tspec := c.NetworkProxySpec()\n\tfor _, h := range spec.DirectHosts {\n\t\tif h == \"token-plan-cn.xiaomimimo.com\" {\n\t\t\tt.Fatalf(\"custom proxy must not force mimo direct; DirectHosts = %v\", spec.DirectHosts)\n\t\t}\n\t}\n}\n"}, "5bf8dec25c55a43baf8d61ed2409032a51e73c74": {"t": 1785750696.1425207, "n": 507, "h": "6ddd9ef77287fab7", "c": "package config\n\nimport \"testing\"\n\nfunc TestDirectProxyHostsFromNoProxyProviders(t *testing.T) {\n\tspec := Default().NetworkProxySpec()\n\thasMimo := false\n\tfor _, h := range spec.DirectHosts {\n\t\tif h == \"token-plan-cn.xiaomimimo.com\" {\n\t\t\thasMimo = true\n\t\t}\n\t\tif h == \"api.deepseek.com\" {\n\t\t\tt.Errorf(\"DeepSeek works through the proxy and must not be forced direct: %v\", spec.DirectHosts)\n\t\t}\n\t}\n\tif !hasMimo {\n\t\tt.Errorf(\"a no_proxy provider's host should land in DirectHosts, got %v\", spec.DirectHosts)\n\t}\n}\n"}}, ".env.example": {"c480afd7570fbb1fb96ff82a0ab117c5df7768e8": {"t": 1786067296.5962796, "n": 200, "h": "3c4b55c269acd5cd", "c": "# Copy to .env and fill in. Only secrets belong here — a provider's base_url and\n# model are configured in reasonix.toml, where api_key_env points at these variables.\n\nDEEPSEEK_API_KEY=\nMIMO_API_KEY=\n"}}, "internal/control/errmsg_test.go": {"c480afd7570fbb1fb96ff82a0ab117c5df7768e8": {"t": 1786068295.9558082, "n": 2692, "h": "3eabbf0051d78a0f", "c": "package control\n\nimport (\n\t\"errors\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"reasonix/internal/i18n\"\n\t\"reasonix/internal/provider\"\n)\n\nfunc TestExplainError(t *testing.T) {\n\tif explainError(nil) != nil {\n\t\tt.Error(\"nil should stay nil\")\n\t}\n\n\tbal := explainError(&provider.APIError{Provider: \"deepseek\", Status: 402, Body: \"Insufficient Balance\"})\n\tif bal.Error() != i18n.M.ProviderErrInsufficientBalance {\n\t\tt.Errorf(\"402 = %q, want the insufficient-balance message\", bal.Error())\n\t}\n\n\tauth := explainError(&provider.AuthError{Provider: \"deepseek\", KeyEnv: \"DEEPSEEK_API_KEY\", Status: 401})\n\tif !strings.Contains(auth.Error(), \"DEEPSEEK_API_KEY\") {\n\t\tt.Errorf(\"401 should name the key env: %q\", auth.Error())\n\t}\n\tif !strings.Contains(auth.Error(), i18n.M.ProviderErrAuth) {\n\t\tt.Errorf(\"401 without a key should use the missing-key message: %q\", auth.Error())\n\t}\n\n\trejected := explainError(&provider.AuthError{Provider: \"mimo\", KeyEnv: \"MIMO_API_KEY\", Status: 401, HasKey: true})\n\tif !strings.Contains(rejected.Error(), i18n.M.ProviderErrAuthRejected) {\n\t\tt.Errorf(\"401 with a key present should use the server-rejected message: %q\", rejected.Error())\n\t}\n\tif !strings.Contains(rejected.Error(), \"MIMO_API_KEY\") {\n\t\tt.Errorf(\"401 should still name the key env: %q\", rejected.Error())\n\t}\n\n\tfor _, status := range []int{400, 422, 429, 500, 503} {\n\t\tgot := explainError(&provider.APIError{Provider: \"p\", Status: status})\n\t\tif got.Error() == \"\" || got.Error() == (&provider.APIError{Provider: \"p\", Status: status}).Error() {\n\t\t\tt.Errorf(\"status %d should map to a localized message, got %q\", status, got.Error())\n\t\t}\n\t}\n\n\tjsonBody := explainError(&provider.APIError{Provider: \"deepseek\", Status: 400, Body: `{\"error\":{\"message\":\"This model's maximum context length is 65536 tokens.\",\"type\":\"invalid_request_error\"}}`})\n\tif !strings.Contains(jsonBody.Error(), i18n.M.ProviderErrBadRequest) || !strings.Contains(jsonBody.Error(), \"maximum context length\") {\n\t\tt.Errorf(\"400 should append the provider reason from a JSON body, got %q\", jsonBody.Error())\n\t}\n\n\trawBody := explainError(&provider.APIError{Provider: \"deepseek\", Status: 422, Body: \"some unparseable detail\"})\n\tif !strings.Contains(rawBody.Error(), \"some unparseable detail\") {\n\t\tt.Errorf(\"422 should fall back to the raw body, got %q\", rawBody.Error())\n\t}\n\n\tnoLeak := explainError(&provider.APIError{Provider: \"deepseek\", Status: 429, Body: `{\"error\":{\"message\":\"slow down\"}}`})\n\tif noLeak.Error() != i18n.M.ProviderErrRateLimited {\n\t\tt.Errorf(\"429 body must not leak into the message, got %q\", noLeak.Error())\n\t}\n\n\tplain := errors.New(\"some other failure\")\n\tif explainError(plain) != plain {\n\t\tt.Error(\"unknown errors should pass through unchanged\")\n\t}\n}\n"}}, "desktop/settings_app.go": {"c480afd7570fbb1fb96ff82a0ab117c5df7768e8": {"t": 1786068298.8458686, "n": 52349, "h": "7fd6a4e4c62e062c", "c": "package main\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"net/url\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"time\"\n\n\t\"reasonix/internal/agent\"\n\t\"reasonix/internal/boot\"\n\t\"reasonix/internal/config\"\n\t\"reasonix/internal/control\"\n\t\"reasonix/internal/provider\"\n)\n\n// settings_app.go is the desktop Settings panel's command surface: it reads the\n// resolved config and applies edits through internal/config/edit.go (the\n// purpose-built mutation API), then rebuilds the controller so the change takes\n// effect live — the same snapshot→reload→resume pattern as SetModel. Secrets are\n// the exception: they go to the global credentials file (upsertDotEnv), since\n// config stores only the env-var name, not the key.\n\n// --- read ---\n\ntype ProviderView struct {\n\tName string `json:\"name\"`\n\tBuiltIn bool `json:\"builtIn\"`\n\tAdded bool `json:\"added\"`\n\tKind string `json:\"kind\"`\n\tBaseURL string `json:\"baseUrl\"`\n\tModels []string `json:\"models\"`\n\tModelsURL string `json:\"modelsUrl\"`\n\tDefault string `json:\"default\"`\n\tAPIKeyEnv string `json:\"apiKeyEnv\"`\n\tKeySet bool `json:\"keySet\"` // the env var currently resolves to a non-empty value\n\tBalanceURL string `json:\"balanceUrl\"`\n\tContextWindow int `json:\"contextWindow\"`\n\tReasoningProtocol string `json:\"reasoningProtocol\"`\n\tSupportedEfforts []string `json:\"supportedEfforts\"`\n\tDefaultEffort string `json:\"defaultEffort\"`\n}\n\ntype PermissionsView struct {\n\tMode string `json:\"mode\"`\n\tAllow []string `json:\"allow\"`\n\tAsk []string `json:\"ask\"`\n\tDeny []string `json:\"deny\"`\n}\n\ntype SandboxView struct {\n\tBash string `json:\"bash\"`\n\tNetwork bool `json:\"network\"`\n\tWorkspaceRoot string `json:\"workspaceRoot\"`\n\tAllowWrite []string `json:\"allowWrite\"`\n\tShell string `json:\"shell\"` // [tools.shell] prefer: auto|bash|powershell|pwsh\n}\n\ntype NetworkProxyView struct {\n\tType string `json:\"type\"`\n\tServer string `json:\"server\"`\n\tPort int `json:\"port\"`\n\tUsername string `json:\"username\"`\n\tPassword string `json:\"password\"`\n}\n\ntype NetworkView struct {\n\tProxyMode string `json:\"proxyMode\"`\n\tProxyURL string `json:\"proxyUrl\"`\n\tNoProxy string `json:\"noProxy\"`\n\tProxy NetworkProxyView `json:\"proxy\"`\n}\n\ntype AgentView struct {\n\tTemperature float64 `json:\"temperature\"`\n\tMaxSteps int `json:\"maxSteps\"`\n\tPlannerMaxSteps int `json:\"plannerMaxSteps\"`\n\tSystemPrompt string `json:\"systemPrompt\"`\n\tColdResumePrune bool `json:\"coldResumePrune\"`\n\tReasoningLanguage string `json:\"reasoningLanguage\"`\n\tCompactTarget float64 `json:\"compactTarget\"`\n\tCompactRatio float64 `json:\"compactRatio\"`\n}\n\ntype OrchestratorSettingsView struct {\n\tEnabled bool `json:\"enabled\"`\n\tReviewerModel string `json:\"reviewerModel\"`\n\tSecondReviewerModel string `json:\"secondReviewerModel\"`\n\tMaxRetries int `json:\"maxRetries\"`\n\tAutoCommit bool `json:\"autoCommit\"`\n}\n\nconst defaultCompactTarget = 0.5\nconst defaultCompactRatio = 0.8\n\nfunc compactTargetOrDefault(v float64) float64 {\n\tif v <= 0 {\n\t\treturn defaultCompactTarget\n\t}\n\treturn v\n}\n\nfunc compactRatioOrDefault(v float64) float64 {\n\tif v <= 0 {\n\t\treturn defaultCompactRatio\n\t}\n\treturn v\n}\n\nfunc maxOrchRetries(v int) int {\n\tif v <= 0 {\n\t\treturn 3\n\t}\n\treturn v\n}\n\ntype BotAllowlistView struct {\n\tEnabled bool `json:\"enabled\"`\n\tAllowAll bool `json:\"allowAll\"`\n\tQQUsers []string `json:\"qqUsers\"`\n\tFeishuUsers []string `json:\"feishuUsers\"`\n\tWeixinUsers []string `json:\"weixinUsers\"`\n\tQQGroups []string `json:\"qqGroups\"`\n\tFeishuGroups []string `json:\"feishuGroups\"`\n\tWeixinGroups []string `json:\"weixinGroups\"`\n}\n\ntype QQBotView struct {\n\tEnabled bool `json:\"enabled\"`\n\tAppID string `json:\"appId\"`\n\tAppSecretEnv string `json:\"appSecretEnv\"`\n\tSecretSet bool `json:\"secretSet\"`\n\tSandbox bool `json:\"sandbox\"`\n}\n\ntype FeishuBotView struct {\n\tEnabled bool `json:\"enabled\"`\n\tDomain string `json:\"domain\"`\n\tAppID string `json:\"appId\"`\n\tAppSecretEnv string `json:\"appSecretEnv\"`\n\tSecretSet bool `json:\"secretSet\"`\n\tVerificationToken string `json:\"verificationToken\"`\n\tMode string `json:\"mode\"`\n\tWebhookPort int `json:\"webhookPort\"`\n\tRequireMention bool `json:\"requireMention\"`\n}\n\ntype WeixinBotView struct {\n\tEnabled bool `json:\"enabled\"`\n\tAccountID string `json:\"accountId\"`\n\tTokenEnv string `json:\"tokenEnv\"`\n\tTokenSet bool `json:\"tokenSet\"`\n\tAPIBase string `json:\"apiBase\"`\n}\n\ntype BotSettingsView struct {\n\tEnabled bool `json:\"enabled\"`\n\tModel string `json:\"model\"`\n\tToolApprovalMode string `json:\"toolApprovalMode\"`\n\tMaxSteps int `json:\"maxSteps\"`\n\tDebounceMs int `json:\"debounceMs\"`\n\tAllowlist BotAllowlistView `json:\"allowlist\"`\n\tQQ QQBotView `json:\"qq\"`\n\tFeishu FeishuBotView `json:\"feishu\"`\n\tWeixin WeixinBotView `json:\"weixin\"`\n\tConnections []BotConnectionView `json:\"connections\"`\n}\n\n// SettingsView is the whole Settings panel payload.\ntype SettingsView struct {\n\tDefaultModel string `json:\"defaultModel\"`\n\tPlannerModel string `json:\"plannerModel\"`\n\tSubagentModel string `json:\"subagentModel\"`\n\tSubagentEffort string `json:\"subagentEffort\"`\n\tAutoPlan string `json:\"autoPlan\"`\n\tProviders []ProviderView `json:\"providers\"`\n\tOfficialProviders []ProviderView `json:\"officialProviders\"`\n\tPermissions PermissionsView `json:\"permissions\"`\n\tSandbox SandboxView `json:\"sandbox\"`\n\tNetwork NetworkView `json:\"network\"`\n\tAgent AgentView `json:\"agent\"`\n\tOrchestrator OrchestratorSettingsView `json:\"orchestrator\"`\n\tBot BotSettingsView `json:\"bot\"`\n\tDesktopLanguage string `json:\"desktopLanguage\"`\n\tDesktopLayoutStyle string `json:\"desktopLayoutStyle\"`\n\tDesktopTheme string `json:\"desktopTheme\"`\n\tDesktopThemeStyle string `json:\"desktopThemeStyle\"`\n\tCloseBehavior string `json:\"closeBehavior\"`\n\tDisplayMode string `json:\"displayMode\"`\n\tStatusBarStyle string `json:\"statusBarStyle\"`\n\tStatusBarItems []string `json:\"statusBarItems\"`\n\tCheckUpdates bool `json:\"checkUpdates\"`\n\tTelemetry bool `json:\"telemetry\"`\n\tMetrics bool `json:\"metrics\"`\n\tExpandThinking bool `json:\"expandThinking\"`\n\tConfigPath string `json:\"configPath\"`\n\t// ProviderKinds lists the provider implementations the kernel actually\n\t// registered (provider.Kinds()), so the editor's \"kind\" picker offers only\n\t// kinds that resolve — selecting an unregistered one would fail the rebuild.\n\tProviderKinds []string `json:\"providerKinds\"`\n\t// AutoApproveTools is the live YOLO/full-access state (runtime-only, not from\n\t// config), so the panel's toggle reflects whether tool approvals are currently\n\t// being skipped this session.\n\tAutoApproveTools bool `json:\"autoApproveTools\"`\n\t// Bypass is the legacy JSON key for the same live state.\n\tBypass bool `json:\"bypass\"`\n}\n\nfunc nonNil(s []string) []string {\n\tif s == nil {\n\t\treturn []string{}\n\t}\n\treturn s\n}\n\nfunc providerRemovalFallbackRef(c *config.Config, name string) string {\n\tfor i := range c.Providers {\n\t\tp := &c.Providers[i]\n\t\tif p.Name == name || !p.Configured() || len(p.ModelList()) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel()\n\t}\n\treturn \"\"\n}\n\nfunc desktopModelRefsProvider(c *config.Config, ref, name string) bool {\n\tif config.ModelRefsProvider(ref, name) {\n\t\treturn true\n\t}\n\tif e, ok := c.ResolveModel(ref); ok {\n\t\treturn e.Name == name\n\t}\n\treturn false\n}\n\nfunc officialProviderHost(baseURL string) string {\n\tu, err := url.Parse(strings.TrimSpace(baseURL))\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\treturn strings.ToLower(u.Hostname())\n}\n\nfunc officialProviderKindFromEntry(p config.ProviderEntry) string {\n\thost := officialProviderHost(p.BaseURL)\n\tswitch config.CanonicalDesktopOfficialProviderName(p.Name) {\n\tcase \"deepseek\":\n\t\tif host == \"api.deepseek.com\" {\n\t\t\treturn \"deepseek\"\n\t\t}\n\tcase \"mimo-api\":\n\t\tif host == \"api.xiaomimimo.com\" {\n\t\t\treturn \"mimo-api\"\n\t\t}\n\tcase \"mimo-token-plan\":\n\t\tif host == \"token-plan-cn.xiaomimimo.com\" {\n\t\t\treturn \"mimo-token-plan\"\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc isOfficialBuiltInProvider(p config.ProviderEntry) bool {\n\treturn officialProviderKindFromEntry(p) != \"\"\n}\n\nfunc providerAccessSet(names []string) map[string]bool {\n\tout := map[string]bool{}\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name != \"\" {\n\t\t\tout[name] = true\n\t\t}\n\t}\n\treturn out\n}\n\nfunc addProviderAccess(c *config.Config, names ...string) {\n\tseen := providerAccessSet(c.Desktop.ProviderAccess)\n\tfor _, name := range names {\n\t\tname = strings.TrimSpace(name)\n\t\tif name == \"\" || seen[name] {\n\t\t\tcontinue\n\t\t}\n\t\tc.Desktop.ProviderAccess = append(c.Desktop.ProviderAccess, name)\n\t\tseen[name] = true\n\t}\n}\n\nfunc removeProviderAccess(c *config.Config, names ...string) {\n\tremove := providerAccessSet(names)\n\tif len(remove) == 0 {\n\t\treturn\n\t}\n\tout := c.Desktop.ProviderAccess[:0]\n\tfor _, name := range c.Desktop.ProviderAccess {\n\t\tif !remove[name] {\n\t\t\tout = append(out, name)\n\t\t}\n\t}\n\tc.Desktop.ProviderAccess = out\n}\n\nfunc providerViewFromEntry(p config.ProviderEntry, builtIn, added bool) ProviderView {\n\treturn ProviderView{\n\t\tName: p.Name, BuiltIn: builtIn, Added: added, Kind: p.Kind, BaseURL: p.BaseURL,\n\t\tModels: nonNil(p.ChatModelList()), ModelsURL: p.ModelsURL, Default: p.DefaultModel(),\n\t\tAPIKeyEnv: p.APIKeyEnv,\n\t\tKeySet: p.APIKeyEnv != \"\" && os.Getenv(p.APIKeyEnv) != \"\",\n\t\tBalanceURL: p.BalanceURL,\n\t\tContextWindow: p.ContextWindow,\n\t\tReasoningProtocol: p.ReasoningProtocol,\n\t\tSupportedEfforts: nonNil(p.SupportedEfforts),\n\t\tDefaultEffort: p.DefaultEffort,\n\t}\n}\n\nfunc officialProviderViews(added map[string]bool) []ProviderView {\n\tvar out []ProviderView\n\tfor _, kind := range []string{\"deepseek\", \"mimo-api\", \"mimo-token-plan\"} {\n\t\tentries, _, err := officialProviderTemplate(kind)\n\t\tif err != nil {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, entry := range entries {\n\t\t\tout = append(out, providerViewFromEntry(entry, true, added[entry.Name]))\n\t\t}\n\t}\n\treturn out\n}\n\nfunc officialProviderAddedSet(cfg *config.Config) map[string]bool {\n\tout := map[string]bool{}\n\tif cfg == nil {\n\t\treturn out\n\t}\n\taccess := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tfor i := range cfg.Providers {\n\t\tp := cfg.Providers[i]\n\t\tif !access[p.Name] {\n\t\t\tcontinue\n\t\t}\n\t\tif kind := officialProviderKindFromEntry(p); kind != \"\" {\n\t\t\tout[kind] = true\n\t\t}\n\t}\n\treturn out\n}\n\n// Settings returns the current configuration for the Settings panel.\nfunc (a *App) Settings() SettingsView {\n\tcfg, cfgPath, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn SettingsView{\n\t\t\tProviders: []ProviderView{},\n\t\t\tOfficialProviders: officialProviderViews(map[string]bool{}),\n\t\t\tProviderKinds: nonNil(provider.Kinds()),\n\t\t\tPermissions: PermissionsView{\n\t\t\t\tMode: \"ask\",\n\t\t\t\tAllow: []string{},\n\t\t\t\tAsk: []string{},\n\t\t\t\tDeny: []string{},\n\t\t\t},\n\t\t\tSandbox: SandboxView{Bash: \"enforce\", AllowWrite: []string{}, Shell: \"auto\"},\n\t\t\tAgent: AgentView{PlannerMaxSteps: 12, ColdResumePrune: true, ReasoningLanguage: \"auto\", CompactTarget: 0.5, CompactRatio: 0.8},\n\t\t\tOrchestrator: OrchestratorSettingsView{Enabled: false, ReviewerModel: \"\", SecondReviewerModel: \"\", MaxRetries: 3, AutoCommit: false},\n\t\t\tBot: botSettingsView(config.BotConfig{}),\n\t\t\tAutoPlan: \"off\",\n\t\t\tDesktopLayoutStyle: \"classic\",\n\t\t\tDesktopTheme: \"light\",\n\t\t\tDesktopThemeStyle: \"graphite\",\n\t\t\tCloseBehavior: \"background\",\n\t\t\tDisplayMode: \"standard\",\n\t\t\tStatusBarStyle: \"text\",\n\t\t\tStatusBarItems: config.DefaultDesktopStatusBarItems(),\n\t\t\tCheckUpdates: true,\n\t\t\tTelemetry: true,\n\t\t\tMetrics: false,\n\t\t\tExpandThinking: false,\n\t\t}\n\t}\n\tctrl := a.activeCtrl()\n\tbash := cfg.Sandbox.Bash\n\tif bash == \"\" {\n\t\tbash = \"enforce\"\n\t}\n\tshell := cfg.Tools.Shell.Prefer\n\tif shell == \"\" {\n\t\tshell = \"auto\"\n\t}\n\tv := SettingsView{\n\t\tDefaultModel: cfg.DefaultModel,\n\t\tPlannerModel: cfg.Agent.PlannerModel,\n\t\tSubagentModel: cfg.Agent.SubagentModel,\n\t\tSubagentEffort: cfg.Agent.SubagentEffort,\n\t\tAutoPlan: desktopAutoPlanMode(cfg.Agent.AutoPlan),\n\t\tProviders: []ProviderView{},\n\t\tOfficialProviders: []ProviderView{},\n\t\tPermissions: PermissionsView{\n\t\t\tMode: orDefault(cfg.Permissions.Mode, \"ask\"),\n\t\t\tAllow: nonNil(cfg.Permissions.Allow),\n\t\t\tAsk: nonNil(cfg.Permissions.Ask),\n\t\t\tDeny: nonNil(cfg.Permissions.Deny),\n\t\t},\n\t\tSandbox: SandboxView{\n\t\t\tBash: bash, Network: cfg.Sandbox.Network,\n\t\t\tWorkspaceRoot: cfg.Sandbox.WorkspaceRoot, AllowWrite: nonNil(cfg.Sandbox.AllowWrite),\n\t\t\tShell: shell,\n\t\t},\n\t\tNetwork: NetworkView{\n\t\t\tProxyMode: cfg.NetworkProxyMode(),\n\t\t\tProxyURL: cfg.Network.ProxyURL,\n\t\t\tNoProxy: cfg.Network.NoProxy,\n\t\t\tProxy: NetworkProxyView{\n\t\t\t\tType: orDefault(cfg.Network.Proxy.Type, \"socks5\"),\n\t\t\t\tServer: cfg.Network.Proxy.Server,\n\t\t\t\tPort: cfg.Network.Proxy.Port,\n\t\t\t\tUsername: cfg.Network.Proxy.Username,\n\t\t\t\tPassword: cfg.Network.Proxy.Password,\n\t\t\t},\n\t\t},\n\t\tAgent: AgentView{\n\t\t\tTemperature: cfg.Agent.Temperature,\n\t\t\tMaxSteps: cfg.Agent.MaxSteps,\n\t\t\tPlannerMaxSteps: cfg.Agent.PlannerMaxSteps,\n\t\t\tSystemPrompt: cfg.Agent.SystemPrompt,\n\t\t\tColdResumePrune: cfg.ColdResumePruneEnabled(),\n\t\t\tReasoningLanguage: cfg.ReasoningLanguage(),\n\t\t\tCompactTarget: compactTargetOrDefault(cfg.Agent.CompactTarget),\n\t\t\tCompactRatio: compactRatioOrDefault(cfg.Agent.CompactRatio),\n\t\t},\n\t\tOrchestrator: OrchestratorSettingsView{\n\t\t\tEnabled: cfg.Orchestrator.Enabled,\n\t\t\tReviewerModel: cfg.Orchestrator.ReviewerModel,\n\t\t\tSecondReviewerModel: cfg.Orchestrator.SecondReviewerModel,\n\t\t\tMaxRetries: maxOrchRetries(cfg.Orchestrator.MaxRetries),\n\t\t\tAutoCommit: cfg.Orchestrator.AutoCommit,\n\t\t},\n\t\tBot: botSettingsView(cfg.Bot),\n\t\tDesktopLanguage: cfg.DesktopLanguage(),\n\t\tDesktopLayoutStyle: cfg.DesktopLayoutStyle(),\n\t\tDesktopTheme: cfg.DesktopTheme(),\n\t\tDesktopThemeStyle: cfg.DesktopThemeStyle(),\n\t\tCloseBehavior: cfg.DesktopCloseBehavior(),\n\t\tDisplayMode: cfg.DesktopDisplayMode(),\n\t\tStatusBarStyle: cfg.DesktopStatusBarStyle(),\n\t\tStatusBarItems: cfg.DesktopStatusBarItems(),\n\t\tCheckUpdates: cfg.DesktopCheckUpdates(),\n\t\tTelemetry: cfg.DesktopTelemetry(),\n\t\tMetrics: cfg.DesktopMetrics(),\n\t\tExpandThinking: cfg.Desktop.ExpandThinking,\n\t\tConfigPath: cfgPath,\n\t\tProviderKinds: nonNil(provider.Kinds()),\n\t\tAutoApproveTools: ctrl != nil && ctrl.AutoApproveTools(),\n\t\tBypass: ctrl != nil && ctrl.AutoApproveTools(),\n\t}\n\tadded := providerAccessSet(cfg.Desktop.ProviderAccess)\n\tv.OfficialProviders = officialProviderViews(officialProviderAddedSet(cfg))\n\tfor i := range cfg.Providers {\n\t\tp := &cfg.Providers[i]\n\t\tv.Providers = append(v.Providers, providerViewFromEntry(*p, isOfficialBuiltInProvider(*p), added[p.Name]))\n\t}\n\treturn v\n}\n\nfunc botSettingsView(b config.BotConfig) BotSettingsView {\n\tmode := strings.TrimSpace(b.Feishu.Mode)\n\tif mode == \"\" {\n\t\tmode = \"webhook\"\n\t}\n\treturn BotSettingsView{\n\t\tEnabled: b.Enabled,\n\t\tModel: b.Model,\n\t\tToolApprovalMode: normalizeBotConnectionToolApprovalMode(b.ToolApprovalMode),\n\t\tMaxSteps: b.MaxSteps,\n\t\tDebounceMs: b.DebounceMs,\n\t\tAllowlist: BotAllowlistView{\n\t\t\tEnabled: b.Allowlist.Enabled,\n\t\t\tAllowAll: b.Allowlist.AllowAll,\n\t\t\tQQUsers: nonNil(b.Allowlist.QQUsers),\n\t\t\tFeishuUsers: nonNil(b.Allowlist.FeishuUsers),\n\t\t\tWeixinUsers: nonNil(b.Allowlist.WeixinUsers),\n\t\t\tQQGroups: nonNil(b.Allowlist.QQGroups),\n\t\t\tFeishuGroups: nonNil(b.Allowlist.FeishuGroups),\n\t\t\tWeixinGroups: nonNil(b.Allowlist.WeixinGroups),\n\t\t},\n\t\tQQ: QQBotView{\n\t\t\tEnabled: b.QQ.Enabled,\n\t\t\tAppID: b.QQ.AppID,\n\t\t\tAppSecretEnv: b.QQ.AppSecretEnv,\n\t\t\tSecretSet: strings.TrimSpace(b.QQ.AppSecretEnv) != \"\" && os.Getenv(b.QQ.AppSecretEnv) != \"\",\n\t\t\tSandbox: b.QQ.Sandbox,\n\t\t},\n\t\tFeishu: FeishuBotView{\n\t\t\tEnabled: b.Feishu.Enabled,\n\t\t\tDomain: orDefault(strings.TrimSpace(b.Feishu.Domain), \"feishu\"),\n\t\t\tAppID: b.Feishu.AppID,\n\t\t\tAppSecretEnv: b.Feishu.AppSecretEnv,\n\t\t\tSecretSet: strings.TrimSpace(b.Feishu.AppSecretEnv) != \"\" && os.Getenv(b.Feishu.AppSecretEnv) != \"\",\n\t\t\tVerificationToken: b.Feishu.VerificationToken,\n\t\t\tMode: mode,\n\t\t\tWebhookPort: b.Feishu.WebhookPort,\n\t\t\tRequireMention: b.Feishu.RequireMention,\n\t\t},\n\t\tWeixin: WeixinBotView{\n\t\t\tEnabled: b.Weixin.Enabled,\n\t\t\tAccountID: b.Weixin.AccountID,\n\t\t\tTokenEnv: b.Weixin.TokenEnv,\n\t\t\tTokenSet: strings.TrimSpace(b.Weixin.TokenEnv) != \"\" && os.Getenv(b.Weixin.TokenEnv) != \"\",\n\t\t\tAPIBase: b.Weixin.APIBase,\n\t\t},\n\t\tConnections: botConnectionViews(b.Connections),\n\t}\n}\n\nfunc orDefault(s, def string) string {\n\tif strings.TrimSpace(s) == \"\" {\n\t\treturn def\n\t}\n\treturn s\n}\n\nfunc botDomainOrDefault(domain string) string {\n\tif strings.EqualFold(strings.TrimSpace(domain), \"lark\") {\n\t\treturn \"lark\"\n\t}\n\treturn \"feishu\"\n}\n\n// --- apply (write config, then rebuild the controller so it's live) ---\n\n// applyConfigChange mutates the user-global config and rebuilds the controller so\n// the change takes effect this session. Desktop settings such as providers and\n// keys are account-level, not per-project: writing them to the global config\n// rather than the cwd's reasonix.toml is what lets them survive a workspace switch.\nfunc (a *App) applyConfigChange(mutate func(*config.Config) error) error {\n\tif err := a.ensureActiveTabRebuildAllowed(\"settings\"); err != nil {\n\t\treturn err\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := mutate(cfg); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\treturn a.rebuild()\n}\n\nfunc (a *App) applyConfigOnly(mutate func(*config.Config) error) error {\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := mutate(cfg); err != nil {\n\t\treturn err\n\t}\n\treturn cfg.SaveTo(path)\n}\n\nfunc (a *App) ensureActiveTabRebuildAllowed(setting string) error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"no active tab\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(setting)\n\t}\n\treturn nil\n}\n\nfunc (a *App) loadDesktopUserConfigForEdit() (*config.Config, string, error) {\n\tuserPath := config.UserConfigPath()\n\tif userPath == \"\" {\n\t\treturn nil, \"\", fmt.Errorf(\"cannot resolve user config directory\")\n\t}\n\tif _, err := os.Stat(userPath); err == nil {\n\t\tcfg := config.LoadForEdit(userPath)\n\t\tnormalizeLegacyDesktopProviderAccessForSettings(cfg, userPath)\n\t\tif err := a.migrateLegacyBotConfigToUser(cfg, userPath); err != nil {\n\t\t\treturn nil, \"\", err\n\t\t}\n\t\treturn cfg, userPath, nil\n\t}\n\tcfg := config.LoadForEdit(userPath)\n\tlegacyPath := config.SourcePathForRoot(a.activeWorkspaceRoot())\n\tif legacyPath == \"\" || sameConfigPath(legacyPath, userPath) {\n\t\tnormalizeLegacyDesktopProviderAccessForSettings(cfg, userPath)\n\t\treturn cfg, userPath, nil\n\t}\n\tlegacyCfg := config.LoadForEdit(legacyPath)\n\tnormalizeLegacyDesktopProviderAccessForSettings(legacyCfg, legacyPath)\n\tlegacyCfg.ConfigVersion = config.Default().ConfigVersion\n\tif err := migrateLegacyBotConfigToUser(cfg, legacyCfg, userPath); err != nil {\n\t\treturn nil, \"\", err\n\t}\n\treturn legacyCfg, userPath, nil\n}\n\nfunc (a *App) migrateLegacyBotConfigToUser(userCfg *config.Config, userPath string) error {\n\tif userCfg == nil {\n\t\treturn nil\n\t}\n\tlegacyPath := config.SourcePathForRoot(a.activeWorkspaceRoot())\n\tif legacyPath == \"\" || sameConfigPath(legacyPath, userPath) {\n\t\treturn nil\n\t}\n\tlegacyCfg := config.LoadForEdit(legacyPath)\n\treturn migrateLegacyBotConfigToUser(userCfg, legacyCfg, userPath)\n}\n\nfunc migrateLegacyBotConfigToUser(userCfg, legacyCfg *config.Config, userPath string) error {\n\tif userCfg == nil || legacyCfg == nil || desktopBotConfigConfigured(userCfg.Bot) {\n\t\treturn nil\n\t}\n\tif !desktopBotConfigConfigured(legacyCfg.Bot) {\n\t\treturn nil\n\t}\n\tuserCfg.Bot = legacyCfg.Bot\n\tif err := userCfg.SaveTo(userPath); err != nil {\n\t\treturn fmt.Errorf(\"migrate legacy bot config: %w\", err)\n\t}\n\treturn nil\n}\n\nfunc desktopBotConfigConfigured(bot config.BotConfig) bool {\n\tdefaults := config.Default().Bot\n\tif bot.Enabled || strings.TrimSpace(bot.Model) != \"\" || len(bot.Connections) > 0 {\n\t\treturn true\n\t}\n\tif (bot.MaxSteps != 0 && bot.MaxSteps != defaults.MaxSteps) || (bot.DebounceMs != 0 && bot.DebounceMs != defaults.DebounceMs) {\n\t\treturn true\n\t}\n\tif bot.Allowlist.AllowAll ||\n\t\tlen(bot.Allowlist.QQUsers)+len(bot.Allowlist.FeishuUsers)+len(bot.Allowlist.WeixinUsers) > 0 ||\n\t\tlen(bot.Allowlist.QQGroups)+len(bot.Allowlist.FeishuGroups)+len(bot.Allowlist.WeixinGroups) > 0 {\n\t\treturn true\n\t}\n\tif bot.QQ.Enabled || strings.TrimSpace(bot.QQ.AppID) != \"\" || bot.QQ.AppSecretEnv != defaults.QQ.AppSecretEnv || bot.QQ.Sandbox != defaults.QQ.Sandbox {\n\t\treturn true\n\t}\n\tif bot.Feishu.Enabled ||\n\t\tstrings.TrimSpace(bot.Feishu.AppID) != \"\" ||\n\t\tbot.Feishu.Domain != defaults.Feishu.Domain ||\n\t\tbot.Feishu.AppSecretEnv != defaults.Feishu.AppSecretEnv ||\n\t\tstrings.TrimSpace(bot.Feishu.VerificationToken) != \"\" ||\n\t\tbot.Feishu.Mode != defaults.Feishu.Mode ||\n\t\tbot.Feishu.WebhookPort != defaults.Feishu.WebhookPort ||\n\t\tbot.Feishu.RequireMention != defaults.Feishu.RequireMention {\n\t\treturn true\n\t}\n\tif bot.Weixin.Enabled ||\n\t\tbot.Weixin.AccountID != defaults.Weixin.AccountID ||\n\t\tbot.Weixin.TokenEnv != defaults.Weixin.TokenEnv ||\n\t\tbot.Weixin.APIBase != defaults.Weixin.APIBase {\n\t\treturn true\n\t}\n\treturn false\n}\n\nfunc normalizeLegacyDesktopProviderAccessForSettings(cfg *config.Config, path string) {\n\tif cfg == nil || len(cfg.Desktop.ProviderAccess) > 0 || configDeclaresProviderAccess(path) {\n\t\treturn\n\t}\n\tconfig.NormalizeLegacyDesktopProviderAccess(cfg)\n}\n\nfunc configDeclaresProviderAccess(path string) bool {\n\tif strings.TrimSpace(path) == \"\" {\n\t\treturn false\n\t}\n\tbody, err := os.ReadFile(path)\n\tif err != nil {\n\t\treturn false\n\t}\n\tfor _, line := range strings.Split(string(body), \"\\n\") {\n\t\tif before, _, ok := strings.Cut(line, \"#\"); ok {\n\t\t\tline = before\n\t\t}\n\t\tline = strings.TrimSpace(line)\n\t\tif strings.HasPrefix(line, \"provider_access\") {\n\t\t\trest := strings.TrimSpace(strings.TrimPrefix(line, \"provider_access\"))\n\t\t\treturn strings.HasPrefix(rest, \"=\")\n\t\t}\n\t}\n\treturn false\n}\n\nfunc (a *App) activeWorkspaceRoot() string {\n\ta.mu.RLock()\n\tdefer a.mu.RUnlock()\n\tif tab := a.activeTabLocked(); tab != nil {\n\t\treturn tab.WorkspaceRoot\n\t}\n\treturn \".\"\n}\n\nfunc projectConfigPathForRoot(root string) string {\n\tif strings.TrimSpace(root) == \"\" || root == \".\" {\n\t\treturn \"reasonix.toml\"\n\t}\n\treturn filepath.Join(root, \"reasonix.toml\")\n}\n\nfunc sameConfigPath(a, b string) bool {\n\ta = strings.TrimSpace(a)\n\tb = strings.TrimSpace(b)\n\tif a == \"\" || b == \"\" {\n\t\treturn false\n\t}\n\taAbs, aErr := filepath.Abs(a)\n\tbAbs, bErr := filepath.Abs(b)\n\tif aErr == nil && bErr == nil {\n\t\treturn filepath.Clean(aAbs) == filepath.Clean(bAbs)\n\t}\n\treturn filepath.Clean(a) == filepath.Clean(b)\n}\n\n// rebuild tears down the controller and rebuilds it from the (just-changed)\n// config, carrying the conversation forward. It keeps the active model if it\n// still resolves; otherwise it falls back to the new default. Mirrors SetModel.\nfunc (a *App) rebuild() error {\n\tif a.ctx == nil {\n\t\treturn nil\n\t}\n\ttab := a.activeTab()\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"no active tab\")\n\t}\n\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\treturn rebuildControllerActiveWorkError(\"settings\")\n\t}\n\tvar carried []provider.Message\n\tprevPath := \"\"\n\tif tab.Ctrl != nil {\n\t\tprevPath = tab.Ctrl.SessionPath()\n\t\t_ = a.snapshotTab(tab)\n\t\tcarried = tab.Ctrl.History()\n\t\ttab.Ctrl.Close()\n\t}\n\tmodel := tab.model\n\tif cfg, err := config.LoadForRoot(tab.WorkspaceRoot); err == nil {\n\t\tif resolved, fallback, ok := cfg.ResolveModelWithFallback(model); ok {\n\t\t\tif fallback && strings.TrimSpace(model) != \"\" {\n\t\t\t\ta.noticeForTab(tab.ID, fmt.Sprintf(\"model %q is no longer available; switched to %s\", model, resolved))\n\t\t\t}\n\t\t\tmodel = resolved\n\t\t}\n\t}\n\tctrl, err := boot.Build(a.bootContext(), boot.Options{\n\t\tModel: model, RequireKey: false,\n\t\tSink: tab.sink,\n\t\tWorkspaceRoot: tab.WorkspaceRoot,\n\t\tSessionDir: tabSessionDir(tab),\n\t\tEffortOverride: cloneStringPtr(tab.effort),\n\t\tTokenMode: currentTabTokenMode(tab),\n\t})\n\tif err != nil {\n\t\ta.mu.Lock()\n\t\ttab.StartupErr = err.Error()\n\t\ttab.Ready = true\n\t\ta.mu.Unlock()\n\t\ta.emitReady(a.ctx)\n\t\treturn err\n\t}\n\ta.bindControllerDisplayRecorder(ctrl)\n\ta.mu.Lock()\n\ttab.Ctrl = ctrl\n\ttab.model = model\n\ttab.Label = ctrl.Label()\n\ttab.StartupErr = \"\"\n\ttab.Ready = true\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\ta.emitReady(a.ctx)\n\tctrl.EnableInteractiveApproval()\n\tapplyTabModeToController(ctrl, tab.mode)\n\tpath := agent.ContinueSessionPath(prevPath, ctrl.SessionDir(), ctrl.Label())\n\tif len(carried) > 0 {\n\t\tcarried = withFreshSystemPrompt(carried, systemPromptFrom(ctrl.History()))\n\t\tctrl.Resume(&agent.Session{Messages: carried}, path)\n\t} else if path != \"\" {\n\t\tctrl.SetSessionPath(path)\n\t}\n\ta.persistTabSessionPath(tab, path)\n\treturn nil\n}\n\nfunc systemPromptFrom(messages []provider.Message) string {\n\tfor _, m := range messages {\n\t\tif m.Role == provider.RoleSystem {\n\t\t\treturn m.Content\n\t\t}\n\t}\n\treturn \"\"\n}\n\nfunc withFreshSystemPrompt(messages []provider.Message, system string) []provider.Message {\n\tif strings.TrimSpace(system) == \"\" {\n\t\treturn messages\n\t}\n\tout := append([]provider.Message(nil), messages...)\n\tfor i := range out {\n\t\tif out[i].Role == provider.RoleSystem {\n\t\t\tout[i].Content = system\n\t\t\tout[i].ReasoningContent = \"\"\n\t\t\tout[i].ReasoningSignature = \"\"\n\t\t\tout[i].ToolCalls = nil\n\t\t\tout[i].ToolCallID = \"\"\n\t\t\tout[i].Name = \"\"\n\t\t\treturn out\n\t\t}\n\t}\n\treturn append([]provider.Message{{Role: provider.RoleSystem, Content: system}}, out...)\n}\n\n// SetDefaultModel sets the config default and switches the live model to it.\nfunc (a *App) SetDefaultModel(ref string) error {\n\ttab := a.activeTab()\n\tif tab == nil {\n\t\treturn fmt.Errorf(\"no active tab\")\n\t}\n\tprev := tab.model\n\ttab.model = ref\n\tif err := a.applyConfigChange(func(c *config.Config) error {\n\t\tresolved, err := selectableDesktopModelRef(c, ref)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tc.DefaultModel = resolved\n\t\ttab.model = resolved\n\t\treturn nil\n\t}); err != nil {\n\t\ttab.model = prev\n\t\treturn err\n\t}\n\treturn nil\n}\n\n// SetPlannerModel sets (or, with \"\", clears) the two-model planner.\nfunc (a *App) SetPlannerModel(ref string) error {\n\treturn a.applyConfigChange(func(c *config.Config) error {\n\t\tif ref != \"\" {\n\t\t\tresolved, err := selectableDesktopModelRef(c, ref)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tref = resolved\n\t\t}\n\t\tc.Agent.PlannerModel = ref\n\t\treturn nil\n\t})\n}\n\n// SetSubagentModel sets (or clears) the default model used by subagent entry points.\nfunc (a *App) SetSubagentModel(ref string) error {\n\treturn a.applyConfigChange(func(c *config.Config) error {\n\t\tref = strings.TrimSpace(ref)\n\t\tif ref != \"\" {\n\t\t\tresolved, err := selectableDesktopModelRef(c, ref)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tref = resolved\n\t\t}\n\t\tc.Agent.SubagentModel = ref\n\t\treturn nil\n\t})\n}\n\nfunc selectableDesktopModelRef(c *config.Config, ref string) (string, error) {\n\tentry, ok := c.ResolveModel(ref)\n\tif !ok {\n\t\treturn \"\", fmt.Errorf(\"unknown model %q\", ref)\n\t}\n\tif !modelProviderAccessAllowed(providerAccessSet(c.Desktop.ProviderAccess), entry.Name) {\n\t\treturn \"\", fmt.Errorf(\"model %q is not available because provider %q is not added\", ref, entry.Name)\n\t}\n\tif !entry.Configured() {\n\t\treturn \"\", fmt.Errorf(\"model %q is not available because provider %q has no key\", ref, entry.Name)\n\t}\n\treturn entry.Name + \"/\" + entry.Model, nil\n}\n\n// SetSubagentEffort sets (or clears) the default effort used by subagent entry points.\nfunc (a *App) SetSubagentEffort(level string) error {\n\treturn a.applyConfigChange(func(c *config.Config) error {\n\t\tlevel = strings.TrimSpace(level)\n\t\tif level == \"\" || level == \"auto\" {\n\t\t\tc.Agent.SubagentEffort = \"\"\n\t\t\treturn nil\n\t\t}\n\t\tmodel := strings.TrimSpace(c.Agent.SubagentModel)\n\t\tif model == \"\" {\n\t\t\tmodel = c.DefaultModel\n\t\t}\n\t\tentry, ok := c.ResolveModel(model)\n\t\tif !ok {\n\t\t\treturn fmt.Errorf(\"unknown subagent model %q\", model)\n\t\t}\n\t\teffort, err := config.NormalizeEffort(entry, level)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t\tc.Agent.SubagentEffort = effort\n\t\treturn nil\n\t})\n}\n\n// SetAutoPlan updates the automatic plan-mode gate (off|on).\nfunc (a *App) SetAutoPlan(mode string) error {\n\treturn a.applyConfigChange(func(c *config.Config) error { return c.SetAutoPlan(mode) })\n}\n\nfunc desktopAutoPlanMode(mode string) string {\n\tswitch strings.ToLower(strings.TrimSpace(mode)) {\n\tcase \"on\", \"ask\":\n\t\treturn \"on\"\n\tdefault:\n\t\treturn \"off\"\n\t}\n}\n\nfunc officialProviderTemplate(kind string) ([]config.ProviderEntry, string, error) {\n\tswitch strings.ToLower(strings.TrimSpace(kind)) {\n\tcase \"deepseek\", \"deepseek-official\":\n\t\treturn []config.ProviderEntry{{\n\t\t\tName: \"deepseek\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.deepseek.com\",\n\t\t\tModels: []string{\"deepseek-v4-flash\", \"deepseek-v4-pro\"},\n\t\t\tDefault: \"deepseek-v4-flash\",\n\t\t\tAPIKeyEnv: \"DEEPSEEK_API_KEY\",\n\t\t\tBalanceURL: \"https://api.deepseek.com/user/balance\",\n\t\t\tContextWindow: 1_000_000,\n\t\t\tPrices: map[string]*provider.Pricing{\n\t\t\t\t\"deepseek-v4-flash\": &provider.Pricing{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: \"$\"},\n\t\t\t\t\"deepseek-v4-pro\": &provider.Pricing{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: \"$\"},\n\t\t\t},\n\t\t}}, \"DEEPSEEK_API_KEY\", nil\n\tcase \"mimo-api\", \"xiaomi-mimo\", \"xiaomi_mimo\":\n\t\treturn []config.ProviderEntry{{\n\t\t\tName: \"mimo-api\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://api.xiaomimimo.com/v1\",\n\t\t\tModels: []string{\"mimo-v2.5-pro\", \"mimo-v2.5\", \"mimo-v2-omni\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tNoProxy: true,\n\t\t}}, \"MIMO_API_KEY\", nil\n\tcase \"mimo-token-plan\", \"xiaomi-mimo-token-plan\", \"xiaomi_mimo_token_plan\":\n\t\treturn []config.ProviderEntry{{\n\t\t\tName: \"mimo-token-plan\",\n\t\t\tKind: \"openai\",\n\t\t\tBaseURL: \"https://token-plan-cn.xiaomimimo.com/v1\",\n\t\t\tModels: []string{\"mimo-v2.5-pro\", \"mimo-v2.5\"},\n\t\t\tDefault: \"mimo-v2.5-pro\",\n\t\t\tAPIKeyEnv: \"MIMO_API_KEY\",\n\t\t\tContextWindow: 1_048_576,\n\t\t\tNoProxy: true,\n\t\t}}, \"MIMO_API_KEY\", nil\n\tdefault:\n\t\treturn nil, \"\", fmt.Errorf(\"unknown official provider template %q\", kind)\n\t}\n}\n\nfunc chatProviderModels(models []string) []string {\n\tout := make([]string, 0, len(models))\n\tseen := map[string]bool{}\n\tfor _, model := range models {\n\t\tmodel = strings.TrimSpace(model)\n\t\tif model == \"\" || seen[model] || !config.IsLikelyChatModel(model) {\n\t\t\tcontinue\n\t\t}\n\t\tseen[model] = true\n\t\tout = append(out, model)\n\t}\n\treturn out\n}\n\nfunc providerDefaultForModels(currentDefault string, models []string) string {\n\tcurrentDefault = strings.TrimSpace(currentDefault)\n\tif currentDefault != \"\" {\n\t\tfor _, model := range models {\n\t\t\tif model == currentDefault {\n\t\t\t\treturn currentDefault\n\t\t\t}\n\t\t}\n\t}\n\tif len(models) > 0 {\n\t\treturn models[0]\n\t}\n\treturn \"\"\n}\n\n// SaveProvider adds or updates a provider. A single model fills `model`; several\n// fill `models` (with `default`). The shared key/endpoint live on the entry.\nfunc (a *App) SaveProvider(p ProviderView) error {\n\treturn a.applyConfigChange(func(c *config.Config) error {\n\t\te := config.ProviderEntry{Name: p.Name}\n\t\tfor i := range c.Providers {\n\t\t\tif c.Providers[i].Name == p.Name {\n\t\t\t\te = c.Providers[i]\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\te.Name = p.Name\n\t\te.Kind = p.Kind\n\t\te.BaseURL = p.BaseURL\n\t\te.ModelsURL = p.ModelsURL\n\t\te.APIKeyEnv = p.APIKeyEnv\n\t\te.BalanceURL = strings.TrimSpace(p.BalanceURL)\n\t\te.ContextWindow = p.ContextWindow\n\t\te.ReasoningProtocol = p.ReasoningProtocol\n\t\te.SupportedEfforts = p.SupportedEfforts\n\t\te.DefaultEffort = p.DefaultEffort\n\t\te.Model = \"\"\n\t\te.Models = nil\n\t\te.Default = \"\"\n\t\tmodels := chatProviderModels(p.Models)\n\t\tif len(models) > 0 {\n\t\t\te.Model = models[0] // also satisfies validateProvider's model requirement\n\t\t\tif len(models) > 1 {\n\t\t\t\te.Models = models\n\t\t\t\te.Default = providerDefaultForModels(p.Default, models)\n\t\t\t}\n\t\t}\n\t\tif err := c.UpsertProvider(e); err != nil {\n\t\t\treturn err\n\t\t}\n\t\taddProviderAccess(c, p.Name)\n\t\treturn nil\n\t})\n}\n\n// AddOfficialProviderAccess adds one curated desktop provider template to the\n// Settings > Model > Access list. The runtime default providers still exist\n// independently; this only records the user's explicit access setup.\nfunc (a *App) AddOfficialProviderAccess(kind, key string) error {\n\tentries, keyEnv, err := officialProviderTemplate(kind)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif strings.TrimSpace(key) != \"\" && keyEnv != \"\" {\n\t\tif err := upsertDotEnv(keyEnv, key); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\treturn a.applyConfigChange(func(c *config.Config) error {\n\t\tnames := make([]string, 0, len(entries))\n\t\tfor _, e := range entries {\n\t\t\tif err := c.UpsertProvider(e); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t\tnames = append(names, e.Name)\n\t\t}\n\t\taddProviderAccess(c, names...)\n\t\treturn nil\n\t})\n}\n\n// FetchProviderModels probes the provider's OpenAI-compatible model-list\n// endpoint and returns the available model IDs. This is a settings-only helper:\n// it never touches chat request serialization or provider-visible prompt data.\nfunc (a *App) FetchProviderModels(p ProviderView) ([]string, error) {\n\te := config.ProviderEntry{\n\t\tName: p.Name,\n\t\tBaseURL: p.BaseURL,\n\t\tModelsURL: p.ModelsURL,\n\t\tAPIKeyEnv: p.APIKeyEnv,\n\t}\n\tctx, cancel := context.WithTimeout(a.reqCtx(), 15*time.Second)\n\tdefer cancel()\n\tmodels, err := e.FetchModels(ctx)\n\tif err != nil {\n\t\treturn []string{}, err\n\t}\n\treturn nonNil(chatProviderModels(models)), nil\n}\n\n// DeleteProvider removes a provider and retargets open idle tabs that used it.\nfunc (a *App) DeleteProvider(name string) error {\n\treturn a.deleteProviderAndRetargetTabs(name)\n}\n\n// RemoveProviderAccess hides a provider from Settings > Model > Access and from\n// settings model pickers. Built-in provider entries remain in the runtime config\n// for back-compat, but visible defaults and idle tabs are retargeted away from\n// the removed access entry when another accessed provider is available. Custom\n// providers are deleted outright.\nfunc (a *App) RemoveProviderAccess(name string) error {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn fmt.Errorf(\"remove provider access: empty provider name\")\n\t}\n\tcfg, _, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif p, ok := cfg.Provider(name); ok && isOfficialBuiltInProvider(*p) {\n\t\treturn a.removeBuiltInProviderAccessAndRetargetTabs(name)\n\t}\n\treturn a.deleteProviderAndRetargetTabs(name)\n}\n\ntype providerRemovalTab struct {\n\tid string\n\tctrl *control.Controller\n\treadOnly bool\n}\n\nfunc providerAccessFallbackRef(c *config.Config, name string) string {\n\tname = strings.TrimSpace(name)\n\tfor _, candidate := range c.Desktop.ProviderAccess {\n\t\tcandidate = strings.TrimSpace(candidate)\n\t\tif candidate == \"\" || candidate == name {\n\t\t\tcontinue\n\t\t}\n\t\tp, ok := c.Provider(candidate)\n\t\tif !ok || len(p.ModelList()) == 0 {\n\t\t\tcontinue\n\t\t}\n\t\treturn p.Name + \"/\" + p.DefaultModel()\n\t}\n\treturn \"\"\n}\n\nfunc retargetProviderReferences(c *config.Config, name, fallbackRef string) {\n\tif strings.TrimSpace(fallbackRef) == \"\" {\n\t\treturn\n\t}\n\tif desktopModelRefsProvider(c, c.DefaultModel, name) {\n\t\tc.DefaultModel = fallbackRef\n\t}\n\tif desktopModelRefsProvider(c, c.Agent.PlannerModel, name) {\n\t\tc.Agent.PlannerModel = fallbackRef\n\t}\n\tif desktopModelRefsProvider(c, c.Agent.SubagentModel, name) {\n\t\tc.Agent.SubagentModel = fallbackRef\n\t}\n\tfor skill, ref := range c.Agent.SubagentModels {\n\t\tif desktopModelRefsProvider(c, ref, name) {\n\t\t\tc.Agent.SubagentModels[skill] = fallbackRef\n\t\t}\n\t}\n}\n\nfunc (a *App) removeBuiltInProviderAccessAndRetargetTabs(name string) error {\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfallbackRef := providerAccessFallbackRef(cfg, name)\n\n\tvar affected []providerRemovalTab\n\tif fallbackRef != \"\" {\n\t\ta.mu.RLock()\n\t\tfor _, id := range a.orderedTabIDsLocked() {\n\t\t\ttab := a.tabs[id]\n\t\t\tif tab == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tref := tab.model\n\t\t\tif strings.TrimSpace(ref) == \"\" {\n\t\t\t\tref = cfg.DefaultModel\n\t\t\t}\n\t\t\tif !desktopModelRefsProvider(cfg, ref, name) {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\t\t\ta.mu.RUnlock()\n\t\t\t\treturn fmt.Errorf(\"finish or cancel active work using %q before removing the provider access\", name)\n\t\t\t}\n\t\t\taffected = append(affected, providerRemovalTab{id: id, ctrl: tab.Ctrl, readOnly: tab.ReadOnly})\n\t\t}\n\t\ta.mu.RUnlock()\n\t}\n\n\tretargetProviderReferences(cfg, name, fallbackRef)\n\tremoveProviderAccess(cfg, name)\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\tif len(affected) == 0 {\n\t\treturn a.rebuild()\n\t}\n\tfor _, item := range affected {\n\t\tif item.ctrl != nil {\n\t\t\tif !item.readOnly {\n\t\t\t\t_ = item.ctrl.Snapshot()\n\t\t\t}\n\t\t\titem.ctrl.Close()\n\t\t}\n\t}\n\n\tvar rebuildTabs []*WorkspaceTab\n\ta.mu.Lock()\n\tfor _, item := range affected {\n\t\ttab := a.tabs[item.id]\n\t\tif tab == nil {\n\t\t\tcontinue\n\t\t}\n\t\ttab.Ctrl = nil\n\t\ttab.model = fallbackRef\n\t\ttab.Label = fallbackRef\n\t\ttab.StartupErr = \"\"\n\t\ttab.Ready = a.ctx == nil\n\t\tif a.ctx != nil {\n\t\t\trebuildTabs = append(rebuildTabs, tab)\n\t\t}\n\t}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\tfor _, tab := range rebuildTabs {\n\t\tgo a.buildTabController(tab)\n\t}\n\treturn nil\n}\n\nfunc (a *App) deleteProviderAndRetargetTabs(name string) error {\n\tname = strings.TrimSpace(name)\n\tif name == \"\" {\n\t\treturn fmt.Errorf(\"remove provider: empty provider name\")\n\t}\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tfallbackRef := providerRemovalFallbackRef(cfg, name)\n\n\tvar affected []providerRemovalTab\n\ta.mu.RLock()\n\tfor _, id := range a.orderedTabIDsLocked() {\n\t\ttab := a.tabs[id]\n\t\tif tab == nil {\n\t\t\tcontinue\n\t\t}\n\t\tref := tab.model\n\t\tif strings.TrimSpace(ref) == \"\" {\n\t\t\tref = cfg.DefaultModel\n\t\t}\n\t\tif !desktopModelRefsProvider(cfg, ref, name) {\n\t\t\tcontinue\n\t\t}\n\t\tif controllerHasActiveRuntimeWork(tab.Ctrl) {\n\t\t\ta.mu.RUnlock()\n\t\t\treturn fmt.Errorf(\"finish or cancel active work using %q before deleting the provider\", name)\n\t\t}\n\t\taffected = append(affected, providerRemovalTab{id: id, ctrl: tab.Ctrl, readOnly: tab.ReadOnly})\n\t}\n\ta.mu.RUnlock()\n\n\tif len(affected) > 0 && fallbackRef == \"\" {\n\t\treturn fmt.Errorf(\"remove provider: %q is used by open tabs and no other configured provider exists\", name)\n\t}\n\tif err := cfg.RemoveProvider(name); err != nil {\n\t\treturn err\n\t}\n\tremoveProviderAccess(cfg, name)\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\n\tif len(affected) == 0 {\n\t\treturn a.rebuild()\n\t}\n\tfor _, item := range affected {\n\t\tif item.ctrl != nil {\n\t\t\tif !item.readOnly {\n\t\t\t\t_ = item.ctrl.Snapshot()\n\t\t\t}\n\t\t\titem.ctrl.Close()\n\t\t}\n\t}\n\n\tvar rebuildTabs []*WorkspaceTab\n\ta.mu.Lock()\n\tfor _, item := range affected {\n\t\ttab := a.tabs[item.id]\n\t\tif tab == nil {\n\t\t\tcontinue\n\t\t}\n\t\ttab.Ctrl = nil\n\t\ttab.model = fallbackRef\n\t\ttab.Label = fallbackRef\n\t\ttab.StartupErr = \"\"\n\t\ttab.Ready = a.ctx == nil\n\t\tif a.ctx != nil {\n\t\t\trebuildTabs = append(rebuildTabs, tab)\n\t\t}\n\t}\n\ta.saveTabsLocked()\n\ta.mu.Unlock()\n\n\tfor _, tab := range rebuildTabs {\n\t\tgo a.buildTabController(tab)\n\t}\n\treturn nil\n}\n\n// SetProviderKey writes a secret to the global credentials file under the given\n// env-var name (the one a provider's api_key_env points at) and rebuilds so it\n// resolves immediately.\nfunc (a *App) SetProviderKey(apiKeyEnv, value string) error {\n\tif strings.TrimSpace(apiKeyEnv) == \"\" {\n\t\treturn fmt.Errorf(\"this provider has no api_key_env set\")\n\t}\n\tif err := a.ensureActiveTabRebuildAllowed(\"provider key\"); err != nil {\n\t\treturn err\n\t}\n\tif err := upsertDotEnv(apiKeyEnv, value); err != nil {\n\t\treturn err\n\t}\n\treturn a.rebuild()\n}\n\n// ClearProviderKey removes a provider secret from the global credentials file\n// and rebuilds so the provider immediately becomes unauthenticated.\nfunc (a *App) ClearProviderKey(apiKeyEnv string) error {\n\tif strings.TrimSpace(apiKeyEnv) == \"\" {\n\t\treturn fmt.Errorf(\"this provider has no api_key_env set\")\n\t}\n\tif err := a.ensureActiveTabRebuildAllowed(\"provider key\"); err != nil {\n\t\treturn err\n\t}\n\tif err := removeDotEnv(apiKeyEnv); err != nil {\n\t\treturn err\n\t}\n\treturn a.rebuild()\n}\n\n// SetPermissionMode sets the writer-fallback mode (ask|allow|deny).\nfunc (a *App) SetPermissionMode(mode string) error {\n\treturn a.applyConfigChange(func(c *config.Config) error { return c.SetPermissionMode(mode) })\n}\n\n// AddPermissionRule appends a rule to the allow/ask/deny list.\nfunc (a *App) AddPermissionRule(list, rule string) error {\n\treturn a.applyConfigChange(func(c *config.Config) error { return c.AddPermissionRule(list, rule) })\n}\n\n// RemovePermissionRule drops a rule from the allow/ask/deny list.\nfunc (a *App) RemovePermissionRule(list, rule string) error {\n\treturn a.applyConfigChange(func(c *config.Config) error {\n\t\t_, err := c.RemovePermissionRule(list, rule)\n\t\treturn err\n\t})\n}\n\n// SetSandbox updates the bash sandbox mode, network egress, and write roots.\nfunc (a *App) SetSandbox(bash string, network bool, workspaceRoot string, allowWrite []string, shell string) error {\n\treturn a.applyConfigChange(func(c *config.Config) error {\n\t\tc.Sandbox.Bash = bash\n\t\tc.Sandbox.Network = network\n\t\tc.Sandbox.WorkspaceRoot = strings.TrimSpace(workspaceRoot)\n\t\tc.Sandbox.AllowWrite = trimList(allowWrite)\n\t\tc.Tools.Shell.Prefer = strings.TrimSpace(shell)\n\t\treturn nil\n\t})\n}\n\n// SetNetwork updates ordinary outbound proxy settings.\nfunc (a *App) SetNetwork(n NetworkView) error {\n\treturn a.applyConfigChange(func(c *config.Config) error {\n\t\treturn c.SetNetwork(config.NetworkConfig{\n\t\t\tProxyMode: n.ProxyMode,\n\t\t\tProxyURL: n.ProxyURL,\n\t\t\tNoProxy: n.NoProxy,\n\t\t\tProxy: config.NetworkProxyConfig{\n\t\t\t\tType: n.Proxy.Type,\n\t\t\t\tServer: n.Proxy.Server,\n\t\t\t\tPort: n.Proxy.Port,\n\t\t\t\tUsername: n.Proxy.Username,\n\t\t\t\tPassword: n.Proxy.Password,\n\t\t\t},\n\t\t})\n\t})\n}\n\nfunc (a *App) SetBotSettings(b BotSettingsView) error {\n\terr := a.applyConfigOnly(func(c *config.Config) error {\n\t\tc.Bot.Enabled = b.Enabled\n\t\tc.Bot.Model = strings.TrimSpace(b.Model)\n\t\tc.Bot.ToolApprovalMode = normalizeBotConnectionToolApprovalMode(b.ToolApprovalMode)\n\t\tc.Bot.MaxSteps = b.MaxSteps\n\t\tc.Bot.DebounceMs = b.DebounceMs\n\t\tc.Bot.Allowlist = config.BotAllowlist{\n\t\t\tEnabled: b.Allowlist.Enabled,\n\t\t\tAllowAll: b.Allowlist.AllowAll,\n\t\t\tQQUsers: trimList(b.Allowlist.QQUsers),\n\t\t\tFeishuUsers: trimList(b.Allowlist.FeishuUsers),\n\t\t\tWeixinUsers: trimList(b.Allowlist.WeixinUsers),\n\t\t\tQQGroups: trimList(b.Allowlist.QQGroups),\n\t\t\tFeishuGroups: trimList(b.Allowlist.FeishuGroups),\n\t\t\tWeixinGroups: trimList(b.Allowlist.WeixinGroups),\n\t\t}\n\t\tc.Bot.QQ = config.QQBotConfig{\n\t\t\tEnabled: b.QQ.Enabled,\n\t\t\tAppID: strings.TrimSpace(b.QQ.AppID),\n\t\t\tAppSecretEnv: strings.TrimSpace(b.QQ.AppSecretEnv),\n\t\t\tSandbox: b.QQ.Sandbox,\n\t\t}\n\t\tc.Bot.Feishu = config.FeishuBotConfig{\n\t\t\tEnabled: b.Feishu.Enabled,\n\t\t\tDomain: botDomainOrDefault(b.Feishu.Domain),\n\t\t\tAppID: strings.TrimSpace(b.Feishu.AppID),\n\t\t\tAppSecretEnv: strings.TrimSpace(b.Feishu.AppSecretEnv),\n\t\t\tVerificationToken: strings.TrimSpace(b.Feishu.VerificationToken),\n\t\t\tMode: strings.TrimSpace(b.Feishu.Mode),\n\t\t\tWebhookPort: b.Feishu.WebhookPort,\n\t\t\tRequireMention: b.Feishu.RequireMention,\n\t\t}\n\t\tc.Bot.Weixin = config.WeixinBotConfig{\n\t\t\tEnabled: b.Weixin.Enabled,\n\t\t\tAccountID: strings.TrimSpace(b.Weixin.AccountID),\n\t\t\tTokenEnv: strings.TrimSpace(b.Weixin.TokenEnv),\n\t\t\tAPIBase: strings.TrimRight(strings.TrimSpace(b.Weixin.APIBase), \"/\"),\n\t\t}\n\t\tc.Bot.Connections = botConnectionConfigs(b.Connections)\n\t\treturn nil\n\t})\n\tif err == nil {\n\t\ta.refreshBotRuntimeAsync()\n\t}\n\treturn err\n}\n\nfunc (a *App) SetBotSecret(envName, value string) error {\n\tenvName = strings.TrimSpace(envName)\n\tif envName == \"\" {\n\t\treturn fmt.Errorf(\"bot secret env name is empty\")\n\t}\n\tif err := upsertDotEnv(envName, value); err != nil {\n\t\treturn err\n\t}\n\ta.refreshBotRuntimeAsync()\n\treturn nil\n}\n\nfunc (a *App) ClearBotSecret(envName string) error {\n\tenvName = strings.TrimSpace(envName)\n\tif envName == \"\" {\n\t\treturn fmt.Errorf(\"bot secret env name is empty\")\n\t}\n\tif err := removeDotEnv(envName); err != nil {\n\t\treturn err\n\t}\n\ta.refreshBotRuntimeAsync()\n\treturn nil\n}\n\n// SetCloseBehavior updates desktop-only window close behavior without rebuilding\n// the active controller. It must stay out of provider-visible prompt/request data.\nfunc (a *App) SetCloseBehavior(mode string) error {\n\treturn a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopCloseBehavior(mode) })\n}\n\n// SetDisplayMode updates the transcript display mode. UI-only, no rebuild needed.\nfunc (a *App) SetDisplayMode(mode string) error {\n\treturn a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopDisplayMode(mode) })\n}\n\n// SetStatusBarStyle updates the desktop status bar metric label style. UI-only,\n// no rebuild needed.\nfunc (a *App) SetStatusBarStyle(style string) error {\n\treturn a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopStatusBarStyle(style) })\n}\n\n// SetStatusBarItems updates the ordered visible desktop status bar items.\n// UI-only, no rebuild needed.\nfunc (a *App) SetStatusBarItems(items []string) error {\n\treturn a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopStatusBarItems(items) })\n}\n\n// SetDesktopLanguage updates only the desktop UI language. It deliberately does\n// not touch config.language, which the CLI/model-facing runtime uses.\nfunc (a *App) SetDesktopLanguage(lang string) error {\n\tif err := a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopLanguage(lang) }); err != nil {\n\t\treturn err\n\t}\n\ta.updateTrayLocale(lang)\n\treturn nil\n}\n\n// SetTrayLocale mirrors the resolved desktop UI language into the native tray\n// menu. It is runtime-only; the persisted preference remains [desktop].language.\nfunc (a *App) SetTrayLocale(locale string) error {\n\tif locale != \"zh\" {\n\t\tlocale = \"en\"\n\t}\n\ta.updateTrayLocale(locale)\n\treturn nil\n}\n\n// SetDesktopAppearance updates only desktop theme preferences. It does not\n// rebuild the active controller and must stay out of provider-visible requests.\nfunc (a *App) SetDesktopAppearance(theme, style string) error {\n\treturn a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopAppearance(theme, style) })\n}\n\n// SetDesktopLayoutStyle updates only the desktop layout style. It does not\n// rebuild the active controller and must stay out of provider-visible requests.\nfunc (a *App) SetDesktopLayoutStyle(style string) error {\n\treturn a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopLayoutStyle(style) })\n}\n\n// SetDesktopCheckUpdates updates only the desktop startup update-check\n// preference. Manual checks in Settings are unaffected.\nfunc (a *App) SetDesktopCheckUpdates(enabled bool) error {\n\treturn a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopCheckUpdates(enabled) })\n}\n\n// SetDesktopTelemetry sets whether the desktop sends the anonymous launch ping.\nfunc (a *App) SetDesktopTelemetry(enabled bool) error {\n\treturn a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopTelemetry(enabled) })\n}\n\n// SetDesktopMetrics sets whether the desktop sends opt-in aggregate agent metrics,\n// starting or stopping the live aggregator so the toggle takes effect immediately.\nfunc (a *App) SetDesktopMetrics(enabled bool) error {\n\tif err := a.applyConfigOnly(func(c *config.Config) error { return c.SetDesktopMetrics(enabled) }); err != nil {\n\t\treturn err\n\t}\n\tswitch {\n\tcase enabled && a.metrics.Load() == nil && version != \"dev\":\n\t\ta.metrics.Store(newMetricsAggregator(filepath.Dir(config.UserConfigPath())))\n\tcase !enabled:\n\t\ta.metrics.Store(nil)\n\t}\n\treturn nil\n}\n\n// SetExpandThinking sets whether reasoning text is expanded by default on\n// the desktop. It is desktop-only and does not rebuild the controller.\nfunc (a *App) SetExpandThinking(on bool) error {\n\treturn a.applyConfigOnly(func(c *config.Config) error { return c.SetExpandThinking(on) })\n}\n\n// MigrateDesktopPreferences imports old browser-local desktop preferences into\n// the user config once. Existing [desktop] values win so stale localStorage never\n// overwrites an explicit config edit.\nfunc (a *App) MigrateDesktopPreferences(language, theme, style string) error {\n\treturn a.applyConfigOnly(func(c *config.Config) error {\n\t\tif strings.TrimSpace(c.Desktop.Language) == \"\" {\n\t\t\tif err := c.SetDesktopLanguage(language); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\tif strings.TrimSpace(c.Desktop.Theme) == \"\" && strings.TrimSpace(c.Desktop.ThemeStyle) == \"\" {\n\t\t\tif err := c.SetDesktopAppearance(theme, style); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t\treturn nil\n\t})\n}\n\n// SetAgentParams updates sampling temperature, optional step guards, and the\n// base system prompt.\nfunc (a *App) SetAgentParams(temperature float64, maxSteps int, plannerMaxSteps int, systemPrompt string) error {\n\treturn a.applyConfigChange(func(c *config.Config) error {\n\t\tc.Agent.Temperature = temperature\n\t\tc.Agent.MaxSteps = maxSteps\n\t\tc.Agent.PlannerMaxSteps = plannerMaxSteps\n\t\tc.Agent.SystemPrompt = systemPrompt\n\t\treturn nil\n\t})\n}\n\nfunc (a *App) SetColdResumePrune(enabled bool) error {\n\treturn a.applyConfigChange(func(c *config.Config) error { return c.SetColdResumePrune(enabled) })\n}\n\nfunc (a *App) SetCompactTarget(v float64) error {\n\treturn a.applyConfigChange(func(c *config.Config) error { return c.SetCompactTarget(v) })\n}\n\nfunc (a *App) SetCompactRatio(v float64) error {\n\treturn a.applyConfigChange(func(c *config.Config) error { return c.SetCompactRatio(v) })\n}\n\nfunc (a *App) SetOrchestratorEnabled(enabled bool) error {\n\treturn a.applyConfigChange(func(c *config.Config) error {\n\t\tc.Orchestrator.Enabled = enabled\n\t\treturn nil\n\t})\n}\n\nfunc (a *App) SetOrchestratorReviewerModel(model string) error {\n\treturn a.applyConfigChange(func(c *config.Config) error {\n\t\tc.Orchestrator.ReviewerModel = model\n\t\treturn nil\n\t})\n}\n\nfunc (a *App) SetOrchestratorSecondReviewerModel(model string) error {\n\treturn a.applyConfigChange(func(c *config.Config) error {\n\t\tc.Orchestrator.SecondReviewerModel = model\n\t\treturn nil\n\t})\n}\n\nfunc (a *App) SetOrchestratorMaxRetries(n int) error {\n\treturn a.applyConfigChange(func(c *config.Config) error {\n\t\tc.Orchestrator.MaxRetries = n\n\t\treturn nil\n\t})\n}\n\nfunc (a *App) SetOrchestratorAutoCommit(on bool) error {\n\treturn a.applyConfigChange(func(c *config.Config) error {\n\t\tc.Orchestrator.AutoCommit = on\n\t\treturn nil\n\t})\n}\n\nfunc (a *App) SetReasoningLanguage(lang string) error {\n\tcfg, path, err := a.loadDesktopUserConfigForEdit()\n\tif err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SetReasoningLanguage(lang); err != nil {\n\t\treturn err\n\t}\n\tif err := cfg.SaveTo(path); err != nil {\n\t\treturn err\n\t}\n\ta.applyReasoningLanguageToLiveControllers(cfg.ReasoningLanguage())\n\treturn nil\n}\n\nfunc (a *App) applyReasoningLanguageToLiveControllers(fallback string) {\n\ttype liveTab struct {\n\t\troot string\n\t\tctrl *control.Controller\n\t}\n\tvar tabs []liveTab\n\ta.mu.RLock()\n\tfor _, tab := range a.tabs {\n\t\tif tab != nil && tab.Ctrl != nil {\n\t\t\ttabs = append(tabs, liveTab{root: tab.WorkspaceRoot, ctrl: tab.Ctrl})\n\t\t}\n\t}\n\ta.mu.RUnlock()\n\tfor _, tab := range tabs {\n\t\tmode := fallback\n\t\tif cfg, err := config.LoadForRoot(tab.root); err == nil {\n\t\t\tmode = cfg.ReasoningLanguage()\n\t\t}\n\t\ttab.ctrl.SetReasoningLanguage(mode)\n\t}\n}\n\n// trimList drops blank entries from a string slice (and returns a non-nil slice).\nfunc trimList(in []string) []string {\n\tout := []string{}\n\tfor _, s := range in {\n\t\tif t := strings.TrimSpace(s); t != \"\" {\n\t\t\tout = append(out, t)\n\t\t}\n\t}\n\treturn out\n}\n"}}}}