1 line
2.1 MiB
Plaintext
1 line
2.1 MiB
Plaintext
{"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"}}}} |