Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
2.2 MiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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/<name>.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 / <name>.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 /<name>. 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# <project>/.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 / <name>.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 /<name>. 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# <project>/.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 / <name>.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 /<name>. 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# <project>/.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 / <name>.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 /<name>. 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# <project>/.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 / <name>.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 /<name>. 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# <project>/.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 / <name>.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 /<name>. 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# <project>/.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, \"<reasoning-language>\") {\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, \"<reasoning-language>\") {\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, \"<reasoning-language>\") {\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, \"<reasoning-language>\") {\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, \"<reasoning-language>\") {\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|<level>\",\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|<level>\",\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"}}}}